Compare commits

..

11 Commits

Author SHA1 Message Date
amlannandy
2df36c11f6 chore: fix loading in summary 2025-08-19 12:41:27 +07:00
amlannandy
a33b13c166 chore: fix tests 2025-08-19 11:57:09 +07:00
amlannandy
700e021228 chore: fix reset query issue 2025-08-19 11:57:09 +07:00
amlannandy
5a0b850e8c chore: remove the filter completion check 2025-08-19 11:57:09 +07:00
amlannandy
70d542e3fe chore: unit tests 2025-08-19 11:57:09 +07:00
amlannandy
cd525221e5 chore: update search bar in inspect modal 2025-08-19 11:57:09 +07:00
amlannandy
7128d78415 chore: additonal changes in summary and unit tests 2025-08-19 11:57:09 +07:00
amlannandy
9edc1fd180 chore: add run query button 2025-08-19 11:57:09 +07:00
amlannandy
de36acf5f1 chore: minor fixes 2025-08-19 11:57:09 +07:00
amlannandy
299c3c1135 chore: replace search bar in inspect 2025-08-19 11:57:09 +07:00
amlannandy
46cf13cd2f chore: replace search bar in summary section 2025-08-19 11:57:09 +07:00
85 changed files with 1097 additions and 4350 deletions

View File

@@ -1,34 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
/**
* Get field keys for a given signal type
* @param signal Type of signal (traces, logs, metrics)
* @param name Optional search text
*/
export const getFieldKeys = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
): Promise<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
}
if (name) {
params.name = name;
}
const response = await ApiBaseInstance.get('/fields/keys', { params });
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldKeys;

View File

@@ -1,63 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
/**
* Get field values for a given signal type and field name
* @param signal Type of signal (traces, logs, metrics)
* @param name Name of the attribute for which values are being fetched
* @param value Optional search text
*/
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
value?: string,
startUnixMilli?: number,
endUnixMilli?: number,
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
}
if (name) {
params.name = name;
}
if (value) {
params.value = value;
}
if (startUnixMilli) {
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
}
if (endUnixMilli) {
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
}
const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {
const allValues: string[] = [];
Object.values(response.data.data.values).forEach((valueArray: any) => {
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
});
// Add a normalized values array to the response
response.data.data.normalizedValues = allValues;
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldValues;

View File

@@ -28,7 +28,6 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -38,7 +37,7 @@ enum ToggleTagValue {
All = 'All',
}
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
placeholder = 'Search...',
@@ -63,8 +62,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
allowClear = false,
onRetry,
maxTagTextLength,
onDropdownVisibleChange,
showIncompleteDataMessage = false,
...rest
}) => {
// ===== State & Refs =====
@@ -81,8 +78,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
const isClickInsideDropdownRef = useRef(false);
const justOpenedRef = useRef<boolean>(false);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Convert single string value to array for consistency
const selectedValues = useMemo(
@@ -129,12 +124,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return allAvailableValues.every((val) => selectedValues.includes(val));
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
@@ -143,18 +132,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Internal onChange Handler =====
const handleInternalChange = useCallback(
(newValue: string | string[], directCaller?: boolean): void => {
(newValue: string | string[]): void => {
// Ensure newValue is an array
const currentNewValue = Array.isArray(newValue) ? newValue : [];
if (
(allOptionShown || isAllSelected) &&
!directCaller &&
currentNewValue.length === 0
) {
return;
}
if (!onChange) return;
// Case 1: Cleared (empty array or undefined)
@@ -163,7 +144,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return;
}
// Case 2: "__ALL__" is selected (means select all actual values)
// Case 2: "__all__" is selected (means select all actual values)
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
const allActualOptions = allAvailableValues.map(
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
@@ -194,14 +175,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
}
},
[
allOptionShown,
isAllSelected,
onChange,
allAvailableValues,
options,
enableAllSelection,
],
[onChange, allAvailableValues, options, enableAllSelection],
);
// ===== Existing Callbacks (potentially needing adjustment later) =====
@@ -536,19 +510,11 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
// Normal single value handling
const trimmedValue = value.trim();
setSearchText(trimmedValue);
setSearchText(value.trim());
if (!isOpen) {
setIsOpen(true);
justOpenedRef.current = true;
}
// Reset active index when search changes if dropdown is open
if (isOpen && trimmedValue) {
setActiveIndex(0);
}
if (onSearch) onSearch(trimmedValue);
if (onSearch) onSearch(value.trim());
},
[onSearch, isOpen, selectedValues, onChange],
);
@@ -562,34 +528,28 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
// If regex fails, return the original text without highlighting
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -600,10 +560,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (isAllSelected) {
// If all are selected, deselect all
handleInternalChange([], true);
handleInternalChange([]);
} else {
// Otherwise, select all
handleInternalChange([ALL_SELECTED_VALUE], true);
handleInternalChange([ALL_SELECTED_VALUE]);
}
}, [options, isAllSelected, handleInternalChange]);
@@ -778,26 +738,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Enhanced keyboard navigation with support for maxTagCount
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>): void => {
// Simple early return if ALL is selected - block all possible keyboard interactions
// that could remove the ALL tag, but still allow dropdown navigation and search
if (
(allOptionShown || isAllSelected) &&
(e.key === 'Backspace' || e.key === 'Delete')
) {
// Only prevent default if the input is empty or cursor is at start position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
const isInputEmpty = isInputActive && !activeElement?.value;
const isCursorAtStart =
isInputActive && activeElement?.selectionStart === 0;
if (isInputEmpty || isCursorAtStart) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!visibleOptions) return [];
@@ -812,7 +752,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (hasAll) {
flatList.push({
label: 'ALL',
value: ALL_SELECTED_VALUE, // Special value for the ALL option
value: '__all__', // Special value for the ALL option
type: 'defined',
});
}
@@ -844,17 +784,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const flatOptions = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && flatOptions.length > 0) {
setActiveIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options and dropdown is open, activate the first one
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
setActiveIndex(0);
}
// Get the active input element to check cursor position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
@@ -1200,7 +1129,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// If there's an active option in the dropdown, prioritize selecting it
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
const selectedOption = flatOptions[activeIndex];
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1230,10 +1159,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
if (onDropdownVisibleChange) {
onDropdownVisibleChange(false);
}
break;
case SPACEKEY:
@@ -1243,7 +1168,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const selectedOption = flatOptions[activeIndex];
// Check if it's the ALL option
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1289,7 +1214,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveIndex(0);
setActiveChipIndex(-1);
break;
@@ -1335,14 +1260,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
},
[
allOptionShown,
isAllSelected,
isOpen,
activeIndex,
getVisibleChipIndices,
getLastVisibleChipIndex,
selectedChips,
isSelectionMode,
isOpen,
activeChipIndex,
selectedValues,
visibleOptions,
@@ -1358,8 +1278,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
startSelection,
selectionEnd,
extendSelection,
onDropdownVisibleChange,
activeIndex,
handleSelectAll,
getVisibleChipIndices,
getLastVisibleChipIndex,
],
);
@@ -1384,14 +1306,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
setIsOpen(false);
}, []);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// Custom dropdown render with sections support
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current search
@@ -1468,7 +1382,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onMouseDown={handleDropdownMouseDown}
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
onBlur={handleBlur}
role="listbox"
aria-multiselectable="true"
@@ -1547,18 +1460,15 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -1584,19 +1494,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -1613,7 +1513,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
handleDropdownMouseDown,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
handleBlur,
activeIndex,
loading,
@@ -1623,31 +1522,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
renderOptionWithIndex,
handleSelectAll,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
]);
// Custom handler for dropdown visibility changes
const handleDropdownVisibleChange = useCallback(
(visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveIndex(0);
setActiveChipIndex(-1);
} else {
setSearchText('');
setActiveIndex(-1);
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
}
// Pass through to the parent component's handler if provided
if (onDropdownVisibleChange) {
onDropdownVisibleChange(visible);
}
},
[onDropdownVisibleChange],
);
// ===== Side Effects =====
// Clear search when dropdown closes
@@ -1712,9 +1588,52 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label, value, closable, onClose } = props;
// If the display value is the special ALL value, render the ALL tag
if (allOptionShown) {
// Don't render a visible tag - will be shown as placeholder
return <div style={{ display: 'none' }} />;
if (value === ALL_SELECTED_VALUE && isAllSelected) {
const handleAllTagClose = (
e: React.MouseEvent | React.KeyboardEvent,
): void => {
e.stopPropagation();
e.preventDefault();
handleInternalChange([]); // Clear selection when ALL tag is closed
};
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
handleAllTagClose(e);
}
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
'ant-select-selection-item-selected': selectedChips.includes(0),
})}
style={
activeChipIndex === 0 || selectedChips.includes(0)
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">ALL</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={handleAllTagClose}
onKeyDown={handleAllTagKeyDown}
role="button"
tabIndex={0}
aria-label="Remove ALL tag (deselect all)"
>
×
</span>
)}
</div>
);
}
// If not isAllSelected, render individual tags using previous logic
@@ -1794,69 +1713,52 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
[
isAllSelected,
handleInternalChange,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
],
);
// Simple onClear handler to prevent clearing ALL
const onClearHandler = useCallback((): void => {
// Skip clearing if ALL is selected
if (allOptionShown || isAllSelected) {
return;
}
// Normal clear behavior
handleInternalChange([], true);
if (onClear) onClear();
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
// ===== Component Rendering =====
return (
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={handleInternalChange}
onClear={(): void => handleInternalChange([])}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? 1 : maxTagCount}
{...rest}
/>
);
};

View File

@@ -29,7 +29,6 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
@@ -58,29 +57,17 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
errorMessage,
allowClear = false,
onRetry,
showIncompleteDataMessage = false,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// Flag to track if dropdown just opened
const justOpenedRef = useRef<boolean>(false);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// ===== Option Filtering & Processing Utilities =====
@@ -143,33 +130,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -269,14 +246,9 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const trimmedValue = value.trim();
setSearchText(trimmedValue);
// Reset active option index when search changes
if (isOpen) {
setActiveOptionIndex(0);
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch, isOpen],
[onSearch],
);
/**
@@ -300,23 +272,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const flatList: OptionData[] = [];
// Process options
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
const { sectionOptions, nonSectionOptions } = splitOptions(
processedOptions,
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
);
// Add custom option if needed
if (
!isEmpty(searchText) &&
!isLabelPresent(processedOptions, searchText)
) {
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
flatList.push({
label: searchText,
value: searchText,
@@ -337,52 +300,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const options = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && options.length > 0) {
setActiveOptionIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options, activate the first one
if (activeOptionIndex === -1 && options.length > 0) {
setActiveOptionIndex(0);
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
break;
case 'ArrowUp':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
} else {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
@@ -395,7 +339,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
@@ -408,7 +351,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -417,7 +359,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
break;
case ' ': // Space key
@@ -428,7 +369,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -439,7 +379,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveOptionIndex(0);
}
},
[
@@ -504,7 +444,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
role="listbox"
tabIndex={-1}
aria-activedescendant={
@@ -515,6 +454,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
@@ -532,16 +472,13 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -567,19 +504,9 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -593,7 +520,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
isLabelPresent,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
activeOptionIndex,
loading,
errorMessage,
@@ -601,22 +527,8 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
dropdownRender,
renderOptionWithIndex,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
]);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveOptionIndex(0);
} else {
setSearchText('');
setActiveOptionIndex(-1);
}
}, []);
// ===== Side Effects =====
// Clear search text when dropdown closes
@@ -670,7 +582,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}

View File

@@ -35,43 +35,6 @@ $custom-border-color: #2c3044;
width: 100%;
position: relative;
&.is-all-selected {
.ant-select-selection-search-input {
caret-color: transparent;
}
.ant-select-selection-placeholder {
opacity: 1 !important;
color: var(--bg-vanilla-400) !important;
font-weight: 500;
visibility: visible !important;
pointer-events: none;
z-index: 2;
.lightMode & {
color: rgba(0, 0, 0, 0.85) !important;
}
}
&.ant-select-focused .ant-select-selection-placeholder {
opacity: 0.45 !important;
}
}
.all-selected-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
z-index: 1;
pointer-events: none;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
.ant-select-selector {
max-height: 200px;
overflow: auto;
@@ -195,7 +158,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 300px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -313,10 +276,6 @@ $custom-border-color: #2c3044;
font-size: 12px;
}
.navigation-text-incomplete {
color: var(--bg-amber-600) !important;
}
.navigation-error {
.navigation-text,
.navigation-icons {
@@ -363,7 +322,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -697,10 +656,6 @@ $custom-border-color: #2c3044;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
font-size: 12px !important;
height: 20px;
line-height: 18px;
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
@@ -881,38 +836,3 @@ $custom-border-color: #2c3044;
}
}
}
.custom-multiselect-wrapper {
position: relative;
width: 100%;
&.all-selected {
.all-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
font-weight: 500;
z-index: 2;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
&:focus-within .all-text {
opacity: 0.45;
}
.ant-select-selection-search-input {
caret-color: auto;
}
.ant-select-selection-placeholder {
display: none;
}
}
}

View File

@@ -24,10 +24,9 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string | null;
errorMessage?: string;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
showIncompleteDataMessage?: boolean;
}
export interface CustomTagProps {
@@ -52,12 +51,10 @@ export interface CustomMultiSelectProps
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string | null;
errorMessage?: string;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
}

View File

@@ -133,15 +133,3 @@ export const filterOptionsBySearch = (
})
.filter(Boolean) as OptionData[];
};
/**
* Utility function to handle dropdown scroll and detect when scrolled to bottom
* Returns true when scrolled to within 20px of the bottom
*/
export const handleScrollToBottom = (
e: React.UIEvent<HTMLDivElement>,
): boolean => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
return scrollHeight - scrollTop - clientHeight < 20;
};

View File

@@ -44,14 +44,13 @@
.lightMode {
.metrics-select-container {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300) !important;
border: 1px solid var(--bg-slate-300) !important;
background: var(--bg-vanilla-100);
color: var(--text-ink-100);
}
.ant-select-dropdown {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300) !important;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
backdrop-filter: none;

View File

@@ -82,12 +82,14 @@ function QuerySearch({
dataSource,
onRun,
signalSource,
isMetricsExplorer = false,
}: {
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
onRun?: (query: string) => void;
isMetricsExplorer?: boolean;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
@@ -208,7 +210,8 @@ function QuerySearch({
async (searchText?: string): Promise<void> => {
if (
dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key
!queryData.aggregateAttribute?.key &&
!isMetricsExplorer
) {
setKeySuggestions([]);
return;
@@ -249,6 +252,7 @@ function QuerySearch({
toggleSuggestions,
queryData.aggregateAttribute?.key,
signalSource,
isMetricsExplorer,
],
);
@@ -1453,6 +1457,7 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
signalSource: '',
isMetricsExplorer: false,
};
export default QuerySearch;

View File

@@ -1,536 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { convertFiltersToExpression } from '../utils';
describe('convertFiltersToExpression', () => {
it('should handle empty, null, and undefined inputs', () => {
// Test null and undefined
expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' });
expect(convertFiltersToExpression(undefined as any)).toEqual({
expression: '',
});
// Test empty filters
expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({
expression: '',
});
expect(
convertFiltersToExpression({ items: undefined, op: 'AND' } as any),
).toEqual({ expression: '' });
});
it('should convert basic comparison operators with proper value formatting', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'service', type: 'string' },
op: '=',
value: 'api-gateway',
},
{
id: '2',
key: { key: 'status', type: 'string' },
op: '!=',
value: 'error',
},
{
id: '3',
key: { key: 'duration', type: 'number' },
op: '>',
value: 100,
},
{
id: '4',
key: { key: 'count', type: 'number' },
op: '<=',
value: 50,
},
{
id: '5',
key: { key: 'is_active', type: 'boolean' },
op: '=',
value: true,
},
{
id: '6',
key: { key: 'enabled', type: 'boolean' },
op: '=',
value: false,
},
{
id: '7',
key: { key: 'count', type: 'number' },
op: '=',
value: 0,
},
{
id: '7',
key: { key: 'regex', type: 'string' },
op: 'regex',
value: '.*',
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'",
});
});
it('should handle string value formatting and escaping', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'message', type: 'string' },
op: '=',
value: "user's data",
},
{
id: '2',
key: { key: 'description', type: 'string' },
op: '=',
value: '',
},
{
id: '3',
key: { key: 'path', type: 'string' },
op: '=',
value: '/api/v1/users',
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'",
});
});
it('should handle IN operator with various value types and array formatting', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'service', type: 'string' },
op: 'IN',
value: ['api-gateway', 'user-service', 'auth-service'],
},
{
id: '2',
key: { key: 'status', type: 'string' },
op: 'IN',
value: 'success', // Single value should be converted to array
},
{
id: '3',
key: { key: 'tags', type: 'string' },
op: 'IN',
value: [], // Empty array
},
{
id: '4',
key: { key: 'name', type: 'string' },
op: 'IN',
value: ["John's", "Mary's", 'Bob'], // Values with quotes
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']",
});
});
it('should convert deprecated operators to their modern equivalents', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'service', type: 'string' },
op: 'nin',
value: ['api-gateway', 'user-service'],
},
{
id: '2',
key: { key: 'message', type: 'string' },
op: 'nlike',
value: 'error',
},
{
id: '3',
key: { key: 'path', type: 'string' },
op: 'nregex',
value: '/api/.*',
},
{
id: '4',
key: { key: 'service', type: 'string' },
op: 'NIN', // Test case insensitivity
value: ['api-gateway'],
},
{
id: '5',
key: { key: 'user_id', type: 'string' },
op: 'nexists',
value: '',
},
{
id: '6',
key: { key: 'description', type: 'string' },
op: 'ncontains',
value: 'error',
},
{
id: '7',
key: { key: 'tags', type: 'string' },
op: 'nhas',
value: 'production',
},
{
id: '8',
key: { key: 'labels', type: 'string' },
op: 'nhasany',
value: ['env:prod', 'service:api'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
});
});
it('should handle non-value operators and function operators', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'user_id', type: 'string' },
op: 'EXISTS',
value: '', // Value should be ignored for EXISTS
},
{
id: '2',
key: { key: 'user_id', type: 'string' },
op: 'EXISTS',
value: 'some-value', // Value should be ignored for EXISTS
},
{
id: '3',
key: { key: 'tags', type: 'string' },
op: 'has',
value: 'production',
},
{
id: '4',
key: { key: 'tags', type: 'string' },
op: 'hasAny',
value: ['production', 'staging'],
},
{
id: '5',
key: { key: 'tags', type: 'string' },
op: 'hasAll',
value: ['production', 'monitoring'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])",
});
});
it('should filter out invalid filters and handle edge cases', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'service', type: 'string' },
op: '=',
value: 'api-gateway',
},
{
id: '2',
key: undefined, // Invalid filter - should be skipped
op: '=',
value: 'error',
},
{
id: '3',
key: { key: '', type: 'string' }, // Invalid filter with empty key - should be skipped
op: '=',
value: 'test',
},
{
id: '4',
key: { key: 'status', type: 'string' },
op: ' = ', // Test whitespace handling
value: 'success',
},
{
id: '5',
key: { key: 'service', type: 'string' },
op: 'In', // Test mixed case handling
value: ['api-gateway'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']",
});
});
it('should handle complex mixed operator scenarios with proper joining', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'service', type: 'string' },
op: 'IN',
value: ['api-gateway', 'user-service'],
},
{
id: '2',
key: { key: 'user_id', type: 'string' },
op: 'EXISTS',
value: '',
},
{
id: '3',
key: { key: 'tags', type: 'string' },
op: 'has',
value: 'production',
},
{
id: '4',
key: { key: 'duration', type: 'number' },
op: '>',
value: 100,
},
{
id: '5',
key: { key: 'status', type: 'string' },
op: 'nin',
value: ['error', 'timeout'],
},
{
id: '6',
key: { key: 'method', type: 'string' },
op: '=',
value: 'POST',
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'",
});
});
it('should handle all numeric comparison operators and edge cases', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'count', type: 'number' },
op: '=',
value: 0,
},
{
id: '2',
key: { key: 'score', type: 'number' },
op: '>',
value: 100,
},
{
id: '3',
key: { key: 'limit', type: 'number' },
op: '>=',
value: 50,
},
{
id: '4',
key: { key: 'threshold', type: 'number' },
op: '<',
value: 1000,
},
{
id: '5',
key: { key: 'max_value', type: 'number' },
op: '<=',
value: 999,
},
{
id: '6',
key: { key: 'values', type: 'string' },
op: 'IN',
value: ['1', '2', '3', '4', '5'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']",
});
});
it('should handle boolean values and string comparisons with special characters', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'is_active', type: 'boolean' },
op: '=',
value: true,
},
{
id: '2',
key: { key: 'is_deleted', type: 'boolean' },
op: '=',
value: false,
},
{
id: '3',
key: { key: 'email', type: 'string' },
op: '=',
value: 'user@example.com',
},
{
id: '4',
key: { key: 'description', type: 'string' },
op: '=',
value: 'Contains "quotes" and \'apostrophes\'',
},
{
id: '5',
key: { key: 'path', type: 'string' },
op: '=',
value: '/api/v1/users/123?filter=true',
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'",
});
});
it('should handle all function operators and complex array scenarios', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'tags', type: 'string' },
op: 'has',
value: 'production',
},
{
id: '2',
key: { key: 'labels', type: 'string' },
op: 'hasAny',
value: ['env:prod', 'service:api'],
},
{
id: '3',
key: { key: 'metadata', type: 'string' },
op: 'hasAll',
value: ['version:1.0', 'team:backend'],
},
{
id: '4',
key: { key: 'services', type: 'string' },
op: 'IN',
value: ['api-gateway', 'user-service', 'auth-service', 'payment-service'],
},
{
id: '5',
key: { key: 'excluded_services', type: 'string' },
op: 'nin',
value: ['legacy-service', 'deprecated-service'],
},
{
id: '6',
key: { key: 'status_codes', type: 'string' },
op: 'IN',
value: ['200', '201', '400', '500'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']",
});
});
it('should handle specific deprecated operators: nhas, ncontains, nexists', () => {
const filters: TagFilter = {
items: [
{
id: '1',
key: { key: 'user_id', type: 'string' },
op: 'nexists',
value: '',
},
{
id: '2',
key: { key: 'description', type: 'string' },
op: 'ncontains',
value: 'error',
},
{
id: '3',
key: { key: 'tags', type: 'string' },
op: 'nhas',
value: 'production',
},
{
id: '4',
key: { key: 'labels', type: 'string' },
op: 'nhasany',
value: ['env:prod', 'service:api'],
},
],
op: 'AND',
};
const result = convertFiltersToExpression(filters);
expect(result).toEqual({
expression:
"user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
});
});
});

View File

@@ -1,10 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import {
DEPRECATED_OPERATORS_MAP,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep } from 'lodash-es';
import { IQueryPair } from 'types/antlrQueryTypes';
@@ -25,7 +21,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
import { isFunctionOperator } from 'utils/tokenUtils';
import { v4 as uuid } from 'uuid';
/**
@@ -91,32 +87,12 @@ export const convertFiltersToExpression = (
return '';
}
let operator = op.trim().toLowerCase();
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(operator)) {
operator =
DEPRECATED_OPERATORS_MAP[
operator as keyof typeof DEPRECATED_OPERATORS_MAP
];
if (isFunctionOperator(op)) {
return `${op}(${key.key}, ${value})`;
}
if (isNonValueOperator(operator)) {
return `${key.key} ${operator}`;
}
if (isFunctionOperator(operator)) {
// Get the proper function name from QUERY_BUILDER_FUNCTIONS
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
const properFunctionName =
functionOperators.find(
(func: string) => func.toLowerCase() === operator.toLowerCase(),
) || operator;
const formattedValue = formatValueForExpression(value, operator);
return `${properFunctionName}(${key.key}, ${formattedValue})`;
}
const formattedValue = formatValueForExpression(value, operator);
return `${key.key} ${operator} ${formattedValue}`;
const formattedValue = formatValueForExpression(value, op);
return `${key.key} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions
@@ -141,6 +117,7 @@ export const convertExpressionToFilters = (
if (!expression) return [];
const queryPairs = extractQueryPairs(expression);
const filters: TagFilterItem[] = [];
queryPairs.forEach((pair) => {
@@ -168,36 +145,19 @@ export const convertFiltersToExpressionWithExistingQuery = (
filters: TagFilter,
existingQuery: string | undefined,
): { filters: TagFilter; filter: { expression: string } } => {
// Check for deprecated operators and replace them with new operators
const updatedFilters = cloneDeep(filters);
// Replace deprecated operators in filter items
if (updatedFilters?.items) {
updatedFilters.items = updatedFilters.items.map((item) => {
const opLower = item.op?.toLowerCase();
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(opLower)) {
return {
...item,
op: DEPRECATED_OPERATORS_MAP[
opLower as keyof typeof DEPRECATED_OPERATORS_MAP
].toLowerCase(),
};
}
return item;
});
}
if (!existingQuery) {
// If no existing query, return filters with a newly generated expression
return {
filters: updatedFilters,
filter: convertFiltersToExpression(updatedFilters),
filters,
filter: convertFiltersToExpression(filters),
};
}
// Extract query pairs from the existing query
const queryPairs = extractQueryPairs(existingQuery.trim());
let queryPairsMap: Map<string, IQueryPair> = new Map();
const updatedFilters = cloneDeep(filters); // Clone filters to avoid direct mutation
const nonExistingFilters: TagFilterItem[] = [];
let modifiedQuery = existingQuery; // We'll modify this query as we proceed
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs

View File

@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/naming-convention */
export const OPERATORS = {
IN: 'IN',
LIKE: 'LIKE',
@@ -23,44 +21,6 @@ export const QUERY_BUILDER_FUNCTIONS = {
HASALL: 'hasAll',
};
export function negateOperator(operatorOrFunction: string): string {
// Special cases for equals/not equals
if (operatorOrFunction === OPERATORS['=']) {
return OPERATORS['!='];
}
if (operatorOrFunction === OPERATORS['!=']) {
return OPERATORS['='];
}
// For all other operators and functions, add NOT in front
return `${OPERATORS.NOT} ${operatorOrFunction}`;
}
export enum DEPRECATED_OPERATORS {
REGEX = 'regex',
NIN = 'nin',
NREGEX = 'nregex',
NLIKE = 'nlike',
NILIKE = 'nilike',
NEXTISTS = 'nexists',
NCONTAINS = 'ncontains',
NHAS = 'nhas',
NHASANY = 'nhasany',
NHASALL = 'nhasall',
}
export const DEPRECATED_OPERATORS_MAP = {
[DEPRECATED_OPERATORS.REGEX]: OPERATORS.REGEXP,
[DEPRECATED_OPERATORS.NIN]: negateOperator(OPERATORS.IN),
[DEPRECATED_OPERATORS.NREGEX]: negateOperator(OPERATORS.REGEXP),
[DEPRECATED_OPERATORS.NLIKE]: negateOperator(OPERATORS.LIKE),
[DEPRECATED_OPERATORS.NILIKE]: negateOperator(OPERATORS.ILIKE),
[DEPRECATED_OPERATORS.NEXTISTS]: negateOperator(OPERATORS.EXISTS),
[DEPRECATED_OPERATORS.NCONTAINS]: negateOperator(OPERATORS.CONTAINS),
[DEPRECATED_OPERATORS.NHAS]: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
[DEPRECATED_OPERATORS.NHASANY]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASANY),
[DEPRECATED_OPERATORS.NHASALL]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASALL),
};
export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS];
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -122,3 +82,15 @@ export const queryOperatorSuggestions = [
{ label: OPERATORS.NOT, type: 'operator', info: 'Not' },
...negationQueryOperatorSuggestions,
];
export function negateOperator(operatorOrFunction: string): string {
// Special cases for equals/not equals
if (operatorOrFunction === OPERATORS['=']) {
return OPERATORS['!='];
}
if (operatorOrFunction === OPERATORS['!=']) {
return OPERATORS['='];
}
// For all other operators and functions, add NOT in front
return `${OPERATORS.NOT} ${operatorOrFunction}`;
}

View File

@@ -49,5 +49,4 @@ export enum QueryParams {
tab = 'tab',
thresholds = 'thresholds',
selectedExplorerView = 'selectedExplorerView',
variableConfigs = 'variableConfigs',
}

View File

@@ -13,6 +13,7 @@ import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
@@ -61,12 +62,14 @@ function GridCardGraph({
const {
toScrollWidgetId,
setToScrollWidgetId,
variablesToGetUpdated,
setDashboardQueryRangeCalled,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryClient = useQueryClient();
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
@@ -117,7 +120,11 @@ function GridCardGraph({
const isEmptyWidget =
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
const queryEnabledCondition =
isVisible &&
!isEmptyWidget &&
isQueryEnabled &&
isEmpty(variablesToGetUpdated);
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
@@ -156,24 +163,22 @@ function GridCardGraph({
};
});
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
// useEffect(() => {
// if (variablesToGetUpdated.length > 0) {
// queryClient.cancelQueries([
// maxTime,
// minTime,
// globalSelectedInterval,
// variables,
// widget?.query,
// widget?.panelTypes,
// widget.timePreferance,
// widget.fillSpans,
// requestData,
// ]);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [variablesToGetUpdated]);
useEffect(() => {
if (variablesToGetUpdated.length > 0) {
queryClient.cancelQueries([
maxTime,
minTime,
globalSelectedInterval,
variables,
widget?.query,
widget?.panelTypes,
widget.timePreferance,
widget.fillSpans,
requestData,
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesToGetUpdated]);
useEffect(() => {
if (!isEqual(updatedQuery, requestData.query)) {
@@ -219,15 +224,6 @@ function GridCardGraph({
widget.timePreferance,
widget.fillSpans,
requestData,
variables
? Object.entries(variables).reduce(
(acc, [id, variable]) => ({
...acc,
[id]: variable.selectedValue,
}),
{},
)
: {},
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),

View File

@@ -256,6 +256,7 @@ function LogsExplorerViewsContainer({
} = useGetExplorerQueryRange(
listChartQuery,
PANEL_TYPES.TIME_SERIES,
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
enabled:
@@ -278,6 +279,7 @@ function LogsExplorerViewsContainer({
} = useGetExplorerQueryRange(
requestData,
panelType,
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
keepPreviousData: true,

View File

@@ -141,19 +141,6 @@
background: var(--bg-vanilla-500);
}
}
.meter-explorer-content-section {
.explore-content {
.time-series-view-panel {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}
}
.meter-explorer-quick-filters-section {
border-right: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -33,7 +33,7 @@ function ExpandedView({
options,
spaceAggregationSeriesMap,
step,
metricInspectionOptions,
appliedMetricInspectionOptions,
timeAggregatedSeriesMap,
}: ExpandedViewProps): JSX.Element {
const [
@@ -44,17 +44,17 @@ function ExpandedView({
useEffect(() => {
logEvent(MetricsExplorerEvents.InspectPointClicked, {
[MetricsExplorerEventKeys.Modal]: 'inspect',
[MetricsExplorerEventKeys.Filters]: metricInspectionOptions.filters,
[MetricsExplorerEventKeys.Filters]: appliedMetricInspectionOptions.filters,
[MetricsExplorerEventKeys.TimeAggregationInterval]:
metricInspectionOptions.timeAggregationInterval,
appliedMetricInspectionOptions.timeAggregationInterval,
[MetricsExplorerEventKeys.TimeAggregationOption]:
metricInspectionOptions.timeAggregationOption,
appliedMetricInspectionOptions.timeAggregationOption,
[MetricsExplorerEventKeys.SpaceAggregationOption]:
metricInspectionOptions.spaceAggregationOption,
appliedMetricInspectionOptions.spaceAggregationOption,
[MetricsExplorerEventKeys.SpaceAggregationLabels]:
metricInspectionOptions.spaceAggregationLabels,
appliedMetricInspectionOptions.spaceAggregationLabels,
});
}, [metricInspectionOptions]);
}, [appliedMetricInspectionOptions]);
useEffect(() => {
if (step !== InspectionStep.COMPLETED) {
@@ -167,7 +167,7 @@ function ExpandedView({
<Typography.Text strong>
{`${absoluteValue} is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
metricInspectionOptions.spaceAggregationOption ??
appliedMetricInspectionOptions.spaceAggregationOption ??
SpaceAggregationOptions.SUM_BY
]
} of`}
@@ -240,7 +240,7 @@ function ExpandedView({
)?.value ?? options?.value
} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
appliedMetricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`
@@ -299,7 +299,7 @@ function ExpandedView({
<Typography.Text strong>
{`${absoluteValue} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
appliedMetricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`}

View File

@@ -29,7 +29,7 @@ function GraphView({
popoverOptions,
setShowExpandedView,
setExpandedViewOptions,
metricInspectionOptions,
appliedMetricInspectionOptions,
isInspectMetricsRefetching,
}: GraphViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -233,7 +233,7 @@ function GraphView({
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
setShowExpandedView={setShowExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
metricInspectionOptions={metricInspectionOptions}
appliedMetricInspectionOptions={appliedMetricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
)}
@@ -255,7 +255,7 @@ function GraphView({
<HoverPopover
options={hoverPopoverOptions}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
appliedMetricInspectionOptions={appliedMetricInspectionOptions}
/>
)}
</div>

View File

@@ -122,6 +122,10 @@
gap: 4px;
.inspect-metrics-query-builder-header {
display: flex;
align-items: center;
justify-content: space-between;
.query-builder-button-label {
display: flex;
align-items: center;

View File

@@ -11,6 +11,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
@@ -24,6 +25,7 @@ import {
MetricInspectionAction,
} from './types';
import { useInspectMetrics } from './useInspectMetrics';
import { useMetricName } from './utils';
function Inspect({
metricName: defaultMetricName,
@@ -31,7 +33,12 @@ function Inspect({
onClose,
}: InspectProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [metricName, setMetricName] = useState<string | null>(defaultMetricName);
const {
currentMetricName,
setCurrentMetricName,
appliedMetricName,
setAppliedMetricName,
} = useMetricName(defaultMetricName);
const [
popoverOptions,
setPopoverOptions,
@@ -42,9 +49,12 @@ function Inspect({
] = useState<GraphPopoverOptions | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const { data: metricDetailsData } = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const { data: metricDetailsData } = useGetMetricDetails(
appliedMetricName ?? '',
{
enabled: !!appliedMetricName,
},
);
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
@@ -97,25 +107,16 @@ function Inspect({
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
} = useInspectMetrics(metricName);
} = useInspectMetrics(appliedMetricName);
const handleDispatchMetricInspectionOptions = useCallback(
(action: MetricInspectionAction): void => {
dispatchMetricInspectionOptions(action);
logEvent(MetricsExplorerEvents.InspectQueryChanged, {
[MetricsExplorerEventKeys.Modal]: 'inspect',
[MetricsExplorerEventKeys.Filters]: metricInspectionOptions.filters,
[MetricsExplorerEventKeys.TimeAggregationInterval]:
metricInspectionOptions.timeAggregationInterval,
[MetricsExplorerEventKeys.TimeAggregationOption]:
metricInspectionOptions.timeAggregationOption,
[MetricsExplorerEventKeys.SpaceAggregationOption]:
metricInspectionOptions.spaceAggregationOption,
[MetricsExplorerEventKeys.SpaceAggregationLabels]:
metricInspectionOptions.spaceAggregationLabels,
});
},
[dispatchMetricInspectionOptions, metricInspectionOptions],
[dispatchMetricInspectionOptions],
);
const selectedMetricType = useMemo(
@@ -128,18 +129,30 @@ function Inspect({
[metricDetailsData],
);
const aggregateAttribute = useMemo(
() => ({
key: currentMetricName ?? '',
dataType: DataTypes.String,
type: selectedMetricType as string,
isColumn: true,
isJSON: false,
id: `${currentMetricName}--${DataTypes.String}--${selectedMetricType}--true`,
}),
[currentMetricName, selectedMetricType],
);
const [currentQueryData, setCurrentQueryData] = useState<IBuilderQuery>({
...searchQuery,
aggregateAttribute,
});
const resetInspection = useCallback(() => {
setShowExpandedView(false);
setPopoverOptions(null);
setExpandedViewOptions(null);
setCurrentQueryData(searchQuery as IBuilderQuery);
reset();
}, [reset]);
// Reset inspection when the selected metric changes
useEffect(() => {
resetInspection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricName]);
}, [reset, searchQuery]);
// Hide expanded view whenever inspection step changes
useEffect(() => {
@@ -193,7 +206,7 @@ function Inspect({
inspectMetricsTimeSeries={aggregatedTimeSeries}
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
resetInspection={resetInspection}
metricName={metricName}
metricName={appliedMetricName}
metricUnit={selectedMetricUnit}
metricType={selectedMetricType}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
@@ -203,19 +216,20 @@ function Inspect({
showExpandedView={showExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
popoverOptions={popoverOptions}
metricInspectionOptions={metricInspectionOptions}
appliedMetricInspectionOptions={metricInspectionOptions.appliedOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
<QueryBuilder
metricName={metricName}
metricType={selectedMetricType}
setMetricName={setMetricName}
currentMetricName={currentMetricName}
setCurrentMetricName={setCurrentMetricName}
setAppliedMetricName={setAppliedMetricName}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
currentMetricInspectionOptions={metricInspectionOptions.currentOptions}
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
searchQuery={searchQuery as IBuilderQuery}
currentQuery={currentQueryData}
setCurrentQuery={setCurrentQueryData}
/>
</div>
<div className="inspect-metrics-content-second-col">
@@ -228,7 +242,7 @@ function Inspect({
options={expandedViewOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
appliedMetricInspectionOptions={metricInspectionOptions.appliedOptions}
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
/>
)}
@@ -244,17 +258,21 @@ function Inspect({
aggregatedTimeSeries,
formattedInspectMetricsTimeSeries,
resetInspection,
metricName,
appliedMetricName,
selectedMetricUnit,
selectedMetricType,
spaceAggregationSeriesMap,
inspectionStep,
showExpandedView,
popoverOptions,
metricInspectionOptions,
metricInspectionOptions.appliedOptions,
metricInspectionOptions.currentOptions,
currentMetricName,
setCurrentMetricName,
setAppliedMetricName,
spaceAggregationLabels,
handleDispatchMetricInspectionOptions,
searchQuery,
currentQueryData,
expandedViewOptions,
timeAggregatedSeriesMap,
]);

View File

@@ -1,5 +1,6 @@
import { Button, Card } from 'antd';
import { Atom } from 'lucide-react';
import { Atom, Play } from 'lucide-react';
import { useCallback } from 'react';
import { QueryBuilderProps } from './types';
import {
@@ -10,16 +11,24 @@ import {
} from './utils';
function QueryBuilder({
metricName,
setMetricName,
currentMetricName,
setCurrentMetricName,
setAppliedMetricName,
spaceAggregationLabels,
metricInspectionOptions,
currentMetricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
searchQuery,
metricType,
currentQuery,
setCurrentQuery,
}: QueryBuilderProps): JSX.Element {
const applyInspectionOptions = useCallback(() => {
setAppliedMetricName(currentMetricName ?? '');
dispatchMetricInspectionOptions({
type: 'APPLY_INSPECTION_OPTIONS',
});
}, [currentMetricName, setAppliedMetricName, dispatchMetricInspectionOptions]);
return (
<div className="inspect-metrics-query-builder">
<div className="inspect-metrics-query-builder-header">
@@ -31,25 +40,36 @@ function QueryBuilder({
>
Query Builder
</Button>
<Button
type="primary"
className="stage-run-query"
icon={<Play size={14} />}
onClick={applyInspectionOptions}
data-testid="apply-query-button"
>
Stage & Run Query
</Button>
</div>
<Card className="inspect-metrics-query-builder-content">
<MetricNameSearch metricName={metricName} setMetricName={setMetricName} />
<MetricNameSearch
currentMetricName={currentMetricName}
setCurrentMetricName={setCurrentMetricName}
/>
<MetricFilters
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
searchQuery={searchQuery}
metricName={metricName}
metricType={metricType || null}
currentQuery={currentQuery}
setCurrentQuery={setCurrentQuery}
/>
<MetricTimeAggregation
inspectionStep={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
currentMetricInspectionOptions={currentMetricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
/>
<MetricSpaceAggregation
inspectionStep={inspectionStep}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
currentMetricInspectionOptions={currentMetricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
/>
</Card>

View File

@@ -11,13 +11,13 @@ function TableView({
setShowExpandedView,
setExpandedViewOptions,
isInspectMetricsRefetching,
metricInspectionOptions,
appliedMetricInspectionOptions,
}: TableViewProps): JSX.Element {
const isSpaceAggregatedWithoutLabel = useMemo(
() =>
!!metricInspectionOptions.spaceAggregationOption &&
metricInspectionOptions.spaceAggregationLabels.length === 0,
[metricInspectionOptions],
!!appliedMetricInspectionOptions.spaceAggregationOption &&
appliedMetricInspectionOptions.spaceAggregationLabels.length === 0,
[appliedMetricInspectionOptions],
);
const labelKeys = useMemo(() => {
if (isSpaceAggregatedWithoutLabel) {

View File

@@ -10,7 +10,7 @@ import ExpandedView from '../ExpandedView';
import {
GraphPopoverData,
InspectionStep,
MetricInspectionOptions,
InspectOptions,
SpaceAggregationOptions,
TimeAggregationOptions,
} from '../types';
@@ -62,7 +62,7 @@ describe('ExpandedView', () => {
],
]);
const mockMetricInspectionOptions: MetricInspectionOptions = {
const mockMetricInspectionOptions: InspectOptions = {
timeAggregationOption: TimeAggregationOptions.MAX,
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
@@ -79,7 +79,7 @@ describe('ExpandedView', () => {
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
appliedMetricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
@@ -96,7 +96,7 @@ describe('ExpandedView', () => {
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.SPACE_AGGREGATION}
metricInspectionOptions={{
appliedMetricInspectionOptions={{
...mockMetricInspectionOptions,
timeAggregationInterval: TIME_AGGREGATION_INTERVAL,
}}
@@ -127,7 +127,7 @@ describe('ExpandedView', () => {
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.COMPLETED}
metricInspectionOptions={mockMetricInspectionOptions}
appliedMetricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);
@@ -153,7 +153,7 @@ describe('ExpandedView', () => {
options={mockOptions}
spaceAggregationSeriesMap={mockSpaceAggregationSeriesMap}
step={InspectionStep.TIME_AGGREGATION}
metricInspectionOptions={mockMetricInspectionOptions}
appliedMetricInspectionOptions={mockMetricInspectionOptions}
timeAggregatedSeriesMap={mockTimeAggregatedSeriesMap}
/>,
);

View File

@@ -60,7 +60,7 @@ describe('GraphView', () => {
setExpandedViewOptions: jest.fn(),
resetInspection: jest.fn(),
showExpandedView: false,
metricInspectionOptions: {
appliedMetricInspectionOptions: {
timeAggregationInterval: 60,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,
spaceAggregationLabels: ['host_name'],

View File

@@ -1,5 +1,5 @@
/* eslint-disable react/jsx-props-no-spreading */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as appContextHooks from 'providers/App/App';
import { QueryClient, QueryClientProvider } from 'react-query';
@@ -22,6 +22,27 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
<div data-testid="mock-aggregator-filter">
<input
data-testid="metric-name-input"
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
onChange({ key: e.target.value })
}
/>
<button
type="button"
data-testid="select-metric-button"
onClick={(): void => onSelect({ key: 'test_metric_2' })}
>
Select Metric
</button>
</div>
),
}));
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -48,12 +69,16 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
const queryClient = new QueryClient();
const mockSetCurrentMetricName = jest.fn();
const mockSetAppliedMetricName = jest.fn();
describe('QueryBuilder', () => {
const defaultProps = {
metricName: 'test_metric',
setMetricName: jest.fn(),
currentMetricName: 'test_metric',
setCurrentMetricName: mockSetCurrentMetricName,
setAppliedMetricName: mockSetAppliedMetricName,
spaceAggregationLabels: ['label1', 'label2'],
metricInspectionOptions: {
currentMetricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.AVG,
spaceAggregationLabels: [],
@@ -67,12 +92,13 @@ describe('QueryBuilder', () => {
metricType: MetricType.SUM,
inspectionStep: InspectionStep.TIME_AGGREGATION,
inspectMetricsTimeSeries: [],
searchQuery: {
currentQuery: {
filters: {
items: [],
op: 'and',
},
} as any,
setCurrentQuery: jest.fn(),
};
beforeEach(() => {
@@ -133,4 +159,57 @@ describe('QueryBuilder', () => {
);
expect(screen.getByTestId('metric-space-aggregation')).toBeInTheDocument();
});
it('should call setCurrentMetricName when metric name is selected', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
const metricNameSearch = screen.getByTestId('metric-name-search');
expect(metricNameSearch).toBeInTheDocument();
expect(screen.getByText('From')).toBeInTheDocument();
const selectButton = screen.getByTestId('select-metric-button');
fireEvent.click(selectButton);
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
});
it('should call setAppliedMetricName when query is applied', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
const applyQueryButton = screen.getByTestId('apply-query-button');
fireEvent.click(applyQueryButton);
expect(mockSetCurrentMetricName).toHaveBeenCalledTimes(0);
expect(mockSetAppliedMetricName).toHaveBeenCalledWith('test_metric');
});
it('should apply inspect options when query is applied', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<QueryBuilder {...defaultProps} />
</Provider>
</QueryClientProvider>,
);
const applyQueryButton = screen.getByTestId('apply-query-button');
fireEvent.click(applyQueryButton);
expect(defaultProps.dispatchMetricInspectionOptions).toHaveBeenCalledWith({
type: 'APPLY_INSPECTION_OPTIONS',
});
});
});

View File

@@ -49,7 +49,7 @@ describe('TableView', () => {
inspectMetricsTimeSeries: mockTimeSeries,
setShowExpandedView: jest.fn(),
setExpandedViewOptions: jest.fn(),
metricInspectionOptions: {
appliedMetricInspectionOptions: {
timeAggregationInterval: 60,
timeAggregationOption: TimeAggregationOptions.MAX,
spaceAggregationOption: SpaceAggregationOptions.MAX_BY,

View File

@@ -72,13 +72,25 @@ export const SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW: Record<
};
export const INITIAL_INSPECT_METRICS_OPTIONS: MetricInspectionOptions = {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
currentOptions: {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
},
appliedOptions: {
timeAggregationOption: undefined,
timeAggregationInterval: undefined,
spaceAggregationOption: undefined,
spaceAggregationLabels: [],
filters: {
items: [],
op: 'AND',
},
},
};

View File

@@ -43,36 +43,36 @@ export interface GraphViewProps {
showExpandedView: boolean;
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
appliedMetricInspectionOptions: InspectOptions;
isInspectMetricsRefetching: boolean;
}
export interface QueryBuilderProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
metricType: MetricType | undefined;
currentMetricName: string | null;
setCurrentMetricName: (metricName: string) => void;
setAppliedMetricName: (metricName: string) => void;
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
currentMetricInspectionOptions: InspectOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
searchQuery: IBuilderQuery;
currentQuery: IBuilderQuery;
setCurrentQuery: (query: IBuilderQuery) => void;
}
export interface MetricNameSearchProps {
metricName: string | null;
setMetricName: (metricName: string) => void;
currentMetricName: string | null;
setCurrentMetricName: (metricName: string) => void;
}
export interface MetricFiltersProps {
searchQuery: IBuilderQuery;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
metricName: string | null;
metricType: MetricType | null;
currentQuery: IBuilderQuery;
setCurrentQuery: (query: IBuilderQuery) => void;
}
export interface MetricTimeAggregationProps {
metricInspectionOptions: MetricInspectionOptions;
currentMetricInspectionOptions: InspectOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
inspectMetricsTimeSeries: InspectMetricsSeries[];
@@ -80,7 +80,7 @@ export interface MetricTimeAggregationProps {
export interface MetricSpaceAggregationProps {
spaceAggregationLabels: string[];
metricInspectionOptions: MetricInspectionOptions;
currentMetricInspectionOptions: InspectOptions;
dispatchMetricInspectionOptions: (action: MetricInspectionAction) => void;
inspectionStep: InspectionStep;
}
@@ -101,7 +101,7 @@ export enum SpaceAggregationOptions {
AVG_BY = 'avg_by',
}
export interface MetricInspectionOptions {
export interface InspectOptions {
timeAggregationOption: TimeAggregationOptions | undefined;
timeAggregationInterval: number | undefined;
spaceAggregationOption: SpaceAggregationOptions | undefined;
@@ -109,13 +109,19 @@ export interface MetricInspectionOptions {
filters: TagFilter;
}
export interface MetricInspectionOptions {
currentOptions: InspectOptions;
appliedOptions: InspectOptions;
}
export type MetricInspectionAction =
| { type: 'SET_TIME_AGGREGATION_OPTION'; payload: TimeAggregationOptions }
| { type: 'SET_TIME_AGGREGATION_INTERVAL'; payload: number }
| { type: 'SET_SPACE_AGGREGATION_OPTION'; payload: SpaceAggregationOptions }
| { type: 'SET_SPACE_AGGREGATION_LABELS'; payload: string[] }
| { type: 'SET_FILTERS'; payload: TagFilter }
| { type: 'RESET_INSPECTION' };
| { type: 'RESET_INSPECTION' }
| { type: 'APPLY_INSPECTION_OPTIONS' };
export enum InspectionStep {
TIME_AGGREGATION = 1,
@@ -156,7 +162,7 @@ export interface ExpandedViewProps {
options: GraphPopoverOptions | null;
spaceAggregationSeriesMap: Map<string, InspectMetricsSeries[]>;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
appliedMetricInspectionOptions: InspectOptions;
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
}
@@ -165,7 +171,7 @@ export interface TableViewProps {
inspectMetricsTimeSeries: InspectMetricsSeries[];
setShowExpandedView: (showExpandedView: boolean) => void;
setExpandedViewOptions: (options: GraphPopoverOptions | null) => void;
metricInspectionOptions: MetricInspectionOptions;
appliedMetricInspectionOptions: InspectOptions;
isInspectMetricsRefetching: boolean;
}

View File

@@ -27,30 +27,55 @@ const metricInspectionReducer = (
case 'SET_TIME_AGGREGATION_OPTION':
return {
...state,
timeAggregationOption: action.payload,
currentOptions: {
...state.currentOptions,
timeAggregationOption: action.payload,
},
};
case 'SET_TIME_AGGREGATION_INTERVAL':
return {
...state,
timeAggregationInterval: action.payload,
currentOptions: {
...state.currentOptions,
timeAggregationInterval: action.payload,
},
};
case 'SET_SPACE_AGGREGATION_OPTION':
return {
...state,
spaceAggregationOption: action.payload,
currentOptions: {
...state.currentOptions,
spaceAggregationOption: action.payload,
},
};
case 'SET_SPACE_AGGREGATION_LABELS':
return {
...state,
spaceAggregationLabels: action.payload,
currentOptions: {
...state.currentOptions,
spaceAggregationLabels: action.payload,
},
};
case 'SET_FILTERS':
return {
...state,
filters: action.payload,
currentOptions: {
...state.currentOptions,
filters: action.payload,
},
};
case 'APPLY_INSPECTION_OPTIONS':
return {
...state,
appliedOptions: {
...state.appliedOptions,
...state.currentOptions,
},
};
case 'RESET_INSPECTION':
return { ...INITIAL_INSPECT_METRICS_OPTIONS };
return {
...INITIAL_INSPECT_METRICS_OPTIONS,
};
default:
return state;
}
@@ -84,7 +109,7 @@ export function useInspectMetrics(
metricName: metricName ?? '',
start,
end,
filters: metricInspectionOptions.filters,
filters: metricInspectionOptions.appliedOptions.filters,
},
{
enabled: !!metricName,
@@ -117,13 +142,26 @@ export function useInspectMetrics(
);
// Evaluate inspection step
const inspectionStep = useMemo(() => {
if (metricInspectionOptions.spaceAggregationOption) {
const currentInspectionStep = useMemo(() => {
if (metricInspectionOptions.currentOptions.spaceAggregationOption) {
return InspectionStep.COMPLETED;
}
if (
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
metricInspectionOptions.currentOptions.timeAggregationOption &&
metricInspectionOptions.currentOptions.timeAggregationInterval
) {
return InspectionStep.SPACE_AGGREGATION;
}
return InspectionStep.TIME_AGGREGATION;
}, [metricInspectionOptions]);
const appliedInspectionStep = useMemo(() => {
if (metricInspectionOptions.appliedOptions.spaceAggregationOption) {
return InspectionStep.COMPLETED;
}
if (
metricInspectionOptions.appliedOptions.timeAggregationOption &&
metricInspectionOptions.appliedOptions.timeAggregationInterval
) {
return InspectionStep.SPACE_AGGREGATION;
}
@@ -149,23 +187,26 @@ export function useInspectMetrics(
// Apply time aggregation once required options are set
if (
inspectionStep >= InspectionStep.SPACE_AGGREGATION &&
metricInspectionOptions.timeAggregationOption &&
metricInspectionOptions.timeAggregationInterval
appliedInspectionStep >= InspectionStep.SPACE_AGGREGATION &&
metricInspectionOptions.appliedOptions.timeAggregationOption &&
metricInspectionOptions.appliedOptions.timeAggregationInterval
) {
const {
timeAggregatedSeries,
timeAggregatedSeriesMap,
} = applyTimeAggregation(inspectMetricsTimeSeries, metricInspectionOptions);
} = applyTimeAggregation(
inspectMetricsTimeSeries,
metricInspectionOptions.appliedOptions,
);
timeSeries = timeAggregatedSeries;
setTimeAggregatedSeriesMap(timeAggregatedSeriesMap);
setAggregatedTimeSeries(timeSeries);
}
// Apply space aggregation
if (inspectionStep === InspectionStep.COMPLETED) {
if (appliedInspectionStep === InspectionStep.COMPLETED) {
const { aggregatedSeries, spaceAggregatedSeriesMap } = applySpaceAggregation(
timeSeries,
metricInspectionOptions,
metricInspectionOptions.appliedOptions,
);
timeSeries = aggregatedSeries;
setSpaceAggregatedSeriesMap(spaceAggregatedSeriesMap);
@@ -186,7 +227,7 @@ export function useInspectMetrics(
const rawData = [timestamps, ...timeseriesArray];
return rawData.map((series) => new Float64Array(series));
}, [inspectMetricsTimeSeries, inspectionStep, metricInspectionOptions]);
}, [inspectMetricsTimeSeries, appliedInspectionStep, metricInspectionOptions]);
const spaceAggregationLabels = useMemo(() => {
const labels = new Set<string>();
@@ -216,7 +257,7 @@ export function useInspectMetrics(
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectionStep: currentInspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap,
aggregatedTimeSeries,

View File

@@ -4,16 +4,12 @@ import logEvent from 'api/common/logEvent';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import classNames from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { HardHat } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -26,8 +22,8 @@ import {
GraphPopoverData,
GraphPopoverOptions,
InspectionStep,
InspectOptions,
MetricFiltersProps,
MetricInspectionOptions,
MetricNameSearchProps,
MetricSpaceAggregationProps,
MetricTimeAggregationProps,
@@ -71,13 +67,13 @@ export function getDefaultTimeAggregationInterval(
}
export function MetricNameSearch({
metricName,
setMetricName,
currentMetricName,
setCurrentMetricName,
}: MetricNameSearchProps): JSX.Element {
const [searchText, setSearchText] = useState(metricName);
const [searchText, setSearchText] = useState(currentMetricName);
const handleSetMetricName = (value: BaseAutocompleteData): void => {
setMetricName(value.key);
setCurrentMetricName(value.key);
};
const handleChange = (value: BaseAutocompleteData): void => {
@@ -102,27 +98,31 @@ export function MetricNameSearch({
export function MetricFilters({
dispatchMetricInspectionOptions,
searchQuery,
metricName,
metricType,
currentQuery,
setCurrentQuery,
}: MetricFiltersProps): JSX.Element {
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: searchQuery,
entityVersion: '',
});
const aggregateAttribute = useMemo(
() => ({
key: metricName ?? '',
dataType: DataTypes.String,
type: metricType,
isColumn: true,
isJSON: false,
id: `${metricName}--${DataTypes.String}--${metricType}--true`,
}),
[metricName, metricType],
);
const handleOnChange = (expression: string): void => {
logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Modal]: 'inspect',
});
const tagFilter = {
items: convertExpressionToFilters(expression),
op: 'AND',
};
setCurrentQuery({
...currentQuery,
filters: tagFilter,
filter: {
...currentQuery.filter,
expression,
},
expression,
});
dispatchMetricInspectionOptions({
type: 'SET_FILTERS',
payload: tagFilter,
});
};
return (
<div
@@ -130,30 +130,19 @@ export function MetricFilters({
className="inspect-metrics-input-group metric-filters"
>
<Typography.Text>Where</Typography.Text>
<QueryBuilderSearch
query={{
...searchQuery,
aggregateAttribute,
}}
onChange={(value): void => {
handleChangeQueryData('filters', value);
logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Modal]: 'inspect',
});
dispatchMetricInspectionOptions({
type: 'SET_FILTERS',
payload: value,
});
}}
suffixIcon={<HardHat size={16} />}
disableNavigationShortcuts
/>
{currentQuery && (
<QuerySearch
queryData={currentQuery}
onChange={handleOnChange}
dataSource={DataSource.METRICS}
/>
)}
</div>
);
}
export function MetricTimeAggregation({
metricInspectionOptions,
currentMetricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
inspectMetricsTimeSeries,
@@ -174,14 +163,14 @@ export function MetricTimeAggregation({
<div className="inspect-metrics-input-group">
<Typography.Text>Align with</Typography.Text>
<Select
value={metricInspectionOptions.timeAggregationOption}
value={currentMetricInspectionOptions.timeAggregationOption}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_OPTION',
payload: value,
});
// set the time aggregation interval to the default value if it is not set
if (!metricInspectionOptions.timeAggregationInterval) {
if (!currentMetricInspectionOptions.timeAggregationInterval) {
dispatchMetricInspectionOptions({
type: 'SET_TIME_AGGREGATION_INTERVAL',
payload: getDefaultTimeAggregationInterval(
@@ -205,7 +194,7 @@ export function MetricTimeAggregation({
<Input
type="number"
className="no-arrows-input"
value={metricInspectionOptions.timeAggregationInterval}
value={currentMetricInspectionOptions.timeAggregationInterval}
placeholder="Select interval..."
suffix="seconds"
onChange={(e): void => {
@@ -224,7 +213,7 @@ export function MetricTimeAggregation({
export function MetricSpaceAggregation({
spaceAggregationLabels,
metricInspectionOptions,
currentMetricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
}: MetricSpaceAggregationProps): JSX.Element {
@@ -243,7 +232,7 @@ export function MetricSpaceAggregation({
<div className="metric-space-aggregation-content">
<div className="metric-space-aggregation-content-left">
<Select
value={metricInspectionOptions.spaceAggregationOption}
value={currentMetricInspectionOptions.spaceAggregationOption}
placeholder="Select option"
onChange={(value): void => {
dispatchMetricInspectionOptions({
@@ -266,7 +255,7 @@ export function MetricSpaceAggregation({
mode="multiple"
style={{ width: '100%' }}
placeholder="Search for attributes..."
value={metricInspectionOptions.spaceAggregationLabels}
value={currentMetricInspectionOptions.spaceAggregationLabels}
onChange={(value): void => {
dispatchMetricInspectionOptions({
type: 'SET_SPACE_AGGREGATION_LABELS',
@@ -322,7 +311,7 @@ export function applyFilters(
export function applyTimeAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
appliedMetricInspectionOptions: InspectOptions,
): {
timeAggregatedSeries: InspectMetricsSeries[];
timeAggregatedSeriesMap: Map<number, GraphPopoverData[]>;
@@ -330,7 +319,7 @@ export function applyTimeAggregation(
const {
timeAggregationOption,
timeAggregationInterval,
} = metricInspectionOptions;
} = appliedMetricInspectionOptions;
if (!timeAggregationInterval) {
return {
@@ -415,7 +404,7 @@ export function applyTimeAggregation(
export function applySpaceAggregation(
inspectMetricsTimeSeries: InspectMetricsSeries[],
metricInspectionOptions: MetricInspectionOptions,
appliedMetricInspectionOptions: InspectOptions,
): {
aggregatedSeries: InspectMetricsSeries[];
spaceAggregatedSeriesMap: Map<string, InspectMetricsSeries[]>;
@@ -425,7 +414,7 @@ export function applySpaceAggregation(
inspectMetricsTimeSeries.forEach((series) => {
// Create composite key from selected labels
const key = metricInspectionOptions.spaceAggregationLabels
const key = appliedMetricInspectionOptions.spaceAggregationLabels
.map((label) => `${label}:${series.labels[label]}`)
.join(',');
@@ -460,7 +449,7 @@ export function applySpaceAggregation(
([timestamp, values]) => {
let aggregatedValue: number;
switch (metricInspectionOptions.spaceAggregationOption) {
switch (appliedMetricInspectionOptions.spaceAggregationOption) {
case SpaceAggregationOptions.SUM_BY:
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
break;
@@ -714,11 +703,11 @@ export function getTimeSeriesLabel(
export function HoverPopover({
options,
step,
metricInspectionOptions,
appliedMetricInspectionOptions,
}: {
options: GraphPopoverOptions;
step: InspectionStep;
metricInspectionOptions: MetricInspectionOptions;
appliedMetricInspectionOptions: InspectOptions;
}): JSX.Element {
const closestTimestamp = useMemo(() => {
if (!options.timeSeries) {
@@ -746,7 +735,7 @@ export function HoverPopover({
const title = useMemo(() => {
if (
step === InspectionStep.COMPLETED &&
metricInspectionOptions.spaceAggregationLabels.length === 0
appliedMetricInspectionOptions.spaceAggregationLabels.length === 0
) {
return undefined;
}
@@ -760,7 +749,7 @@ export function HoverPopover({
options.timeSeries,
options.timeSeries?.strokeColor,
);
}, [step, options.timeSeries, metricInspectionOptions]);
}, [step, options.timeSeries, appliedMetricInspectionOptions]);
return (
<Card
@@ -830,3 +819,26 @@ export function onGraphHover(
timeSeries: series,
});
}
export function useMetricName(
metricName: string | null,
): {
currentMetricName: string | null;
setCurrentMetricName: (metricName: string | null) => void;
appliedMetricName: string | null;
setAppliedMetricName: (metricName: string | null) => void;
} {
const [currentMetricName, setCurrentMetricName] = useState<string | null>(
metricName,
);
const [appliedMetricName, setAppliedMetricName] = useState<string | null>(
metricName,
);
return {
currentMetricName,
setCurrentMetricName,
appliedMetricName,
setAppliedMetricName,
};
}

View File

@@ -1,27 +1,97 @@
import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { Button, Tooltip } from 'antd';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { HardHat, Info } from 'lucide-react';
import { cloneDeep } from 'lodash-es';
import { Info, Play } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
function MetricsSearch({ onChange, query }: MetricsSearchProps): JSX.Element {
const [contextQuery, setContextQuery] = useState<IBuilderQuery | undefined>(
query,
);
useEffect(() => {
setContextQuery(query);
}, [query]);
const handleRunQuery = (expression: string): void => {
let updatedContextQuery = cloneDeep(contextQuery);
if (!updatedContextQuery) {
return;
}
const newFilters: TagFilter = {
items: expression ? convertExpressionToFilters(expression) : [],
op: 'AND',
};
updatedContextQuery = {
...updatedContextQuery,
filter: {
...updatedContextQuery.filter,
expression,
},
filters: {
...updatedContextQuery.filters,
...newFilters,
op: updatedContextQuery.filters?.op ?? 'AND',
},
};
setContextQuery(updatedContextQuery);
onChange(newFilters);
};
const handleOnChange = (expression: string): void => {
let updatedContextQuery = cloneDeep(contextQuery);
if (updatedContextQuery) {
updatedContextQuery = {
...updatedContextQuery,
filter: {
...updatedContextQuery.filter,
expression,
},
};
setContextQuery(updatedContextQuery);
}
};
const handleStageAndRunQuery = (): void =>
handleRunQuery(contextQuery?.filter?.expression || '');
return (
<div className="metrics-search-container">
<div className="qb-search-container">
<div data-testid="qb-search-container" className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
placement="right"
>
<Info size={16} />
</Tooltip>
<QueryBuilderSearch
query={query}
onChange={onChange}
suffixIcon={<HardHat size={16} />}
isMetricsExplorer
/>
{contextQuery && (
<QuerySearch
onChange={handleOnChange}
dataSource={DataSource.METRICS}
queryData={contextQuery}
onRun={handleRunQuery}
isMetricsExplorer
/>
)}
</div>
<Button
type="primary"
onClick={handleStageAndRunQuery}
className="stage-run-query"
icon={<Play size={14} />}
>
Stage & Run Query
</Button>
<div className="metrics-search-options">
<DateTimeSelectionV2
showAutoRefresh={false}

View File

@@ -37,6 +37,7 @@
.metrics-search-container {
display: flex;
align-items: center;
gap: 16px;
.metrics-search-options {

View File

@@ -3,6 +3,7 @@ import './Summary.styles.scss';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
@@ -184,6 +185,7 @@ function Summary(): JSX.Element {
() => ({
...initialQueriesMap.metrics.builder.queryData[0],
filters: queryFilters,
filter: convertFiltersToExpression(queryFilters),
}),
[queryFilters],
);
@@ -290,18 +292,11 @@ function Summary(): JSX.Element {
],
);
console.log({
isMetricsListDataEmpty,
isMetricsTreeMapDataEmpty,
treeMapData,
sec: treeMapData?.payload?.data[heatmapView],
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab">
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
{isMetricsLoading || isTreeMapLoading ? (
{isMetricsLoading && isTreeMapLoading ? (
<MetricsLoading />
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
<NoLogs dataSource={DataSource.METRICS} />

View File

@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import MetricsSearch from '../MetricsSearch';
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">DateTime</div>
),
}));
const mockQuery: IBuilderQuery = {
...initialQueriesMap.metrics.builder.queryData[0],
};
const mockOnChange = jest.fn();
describe('MetricsSearch', () => {
it('should render the search bar, run button and date-time selector', () => {
render(<MetricsSearch query={mockQuery} onChange={mockOnChange} />);
expect(screen.getByText('DateTime')).toBeInTheDocument();
expect(screen.getByText('Stage & Run Query')).toBeInTheDocument();
expect(screen.getByTestId('qb-search-container')).toBeInTheDocument();
});
it('should call onChange with parsed filters when Stage & Run is clicked and expression is present', () => {
render(<MetricsSearch query={mockQuery} onChange={mockOnChange} />);
fireEvent.click(screen.getByText('Stage & Run Query'));
expect(mockOnChange).toHaveBeenCalledWith({
items: [],
op: 'AND',
});
});
});

View File

@@ -1,42 +0,0 @@
.dynamic-variable-container {
display: grid;
grid-template-columns: 1fr 32px 200px;
gap: 32px;
align-items: center;
width: 100%;
margin: 24px 0;
.ant-select {
.ant-select-selector {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
max-width: 300px;
}
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
}
.lightMode {
.dynamic-variable-container {
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -1,179 +0,0 @@
import './DynamicVariable.styles.scss';
import { Select, Typography } from 'antd';
import CustomSelect from 'components/NewSelect/CustomSelect';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import useDebounce from 'hooks/useDebounce';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
enum AttributeSource {
ALL_SOURCES = 'All Sources',
LOGS = 'Logs',
METRICS = 'Metrics',
TRACES = 'Traces',
}
function DynamicVariable({
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue,
}: {
setDynamicVariablesSelectedValue: Dispatch<
SetStateAction<
| {
name: string;
value: string;
}
| undefined
>
>;
dynamicVariablesSelectedValue:
| {
name: string;
value: string;
}
| undefined;
}): JSX.Element {
const sources = [
AttributeSource.ALL_SOURCES,
AttributeSource.LOGS,
AttributeSource.TRACES,
AttributeSource.METRICS,
];
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const [filteredAttributes, setFilteredAttributes] = useState<
Record<string, FieldKey[]>
>({});
useEffect(() => {
if (dynamicVariablesSelectedValue?.name) {
setSelectedAttribute(dynamicVariablesSelectedValue.name);
}
if (dynamicVariablesSelectedValue?.value) {
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
}
}, [
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const { data, error, isLoading, refetch } = useGetFieldKeys({
signal:
attributeSource === AttributeSource.ALL_SOURCES
? undefined
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
name: debouncedApiSearchText,
});
const isComplete = useMemo(() => data?.payload?.complete === true, [data]);
useEffect(() => {
if (data) {
const newAttributes = data.payload?.keys ?? {};
setAttributes(newAttributes);
setFilteredAttributes(newAttributes);
}
}, [data]);
// refetch when attributeSource changes
useEffect(() => {
if (attributeSource) {
refetch();
}
}, [attributeSource, refetch, debouncedApiSearchText]);
// Handle search based on whether we have complete data or not
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
// If complete is true, do client-side filtering
if (!text) {
setFilteredAttributes(attributes);
return;
}
const filtered: Record<string, FieldKey[]> = {};
Object.keys(attributes).forEach((key) => {
if (key.toLowerCase().includes(text.toLowerCase())) {
filtered[key] = attributes[key];
}
});
setFilteredAttributes(filtered);
} else {
// If complete is false, debounce the API call
setApiSearchText(text);
}
},
[attributes, isComplete],
);
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
useEffect(() => {
if (selectedAttribute || attributeSource) {
setDynamicVariablesSelectedValue({
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
value:
attributeSource ||
dynamicVariablesSelectedValue?.value ||
AttributeSource.ALL_SOURCES,
});
}
}, [
selectedAttribute,
attributeSource,
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const errorMessage = (error as any)?.message;
return (
<div className="dynamic-variable-container">
<CustomSelect
placeholder="Select an Attribute"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorMessage ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorMessage as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_SOURCES}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
</div>
);
}
export default DynamicVariable;

View File

@@ -1,376 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import DynamicVariable from '../DynamicVariable';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock dependencies
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
useGetFieldKeys: jest.fn(),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: (value: any): any => value, // Return the same value without debouncing for testing
}));
describe('DynamicVariable Component', () => {
const mockSetDynamicVariablesSelectedValue = jest.fn();
const ATTRIBUTE_PLACEHOLDER = 'Select an Attribute';
const LOADING_TEXT = 'We are updating the values...';
const DEFAULT_PROPS = {
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue: undefined,
};
const mockFieldKeysResponse = {
payload: {
keys: {
'service.name': [],
'http.status_code': [],
duration: [],
},
complete: true,
},
statusCode: 200,
};
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: jest.fn(),
});
});
// Helper function to get the attribute select element
const getAttributeSelect = (): HTMLElement =>
screen.getAllByRole('combobox')[0];
// Helper function to get the source select element
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
it('renders with default state', () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Check for main components
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText('All Sources')).toBeInTheDocument();
expect(screen.getByText('from')).toBeInTheDocument();
});
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
const selectedValue = {
name: 'service.name',
value: 'Logs',
};
render(
<DynamicVariable
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={selectedValue}
/>,
);
// Verify values are set
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
it('shows loading state when fetching data', () => {
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: null,
isLoading: true,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show loading state
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
});
it('shows error message when API fails', () => {
const errorMessage = 'Failed to fetch field keys';
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: errorMessage },
isLoading: false,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show error message
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('updates filteredAttributes when data is loaded', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear in the dropdown
await waitFor(() => {
// Looking for option-content elements inside the CustomSelect dropdown
const options = document.querySelectorAll('.option-content');
expect(options.length).toBeGreaterThan(0);
// Check if all expected options are present
let foundServiceName = false;
let foundHttpStatusCode = false;
let foundDuration = false;
options.forEach((option) => {
const text = option.textContent?.trim();
if (text === 'service.name') foundServiceName = true;
if (text === 'http.status_code') foundHttpStatusCode = true;
if (text === 'duration') foundDuration = true;
});
expect(foundServiceName).toBe(true);
expect(foundHttpStatusCode).toBe(true);
expect(foundDuration).toBe(true);
});
});
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear, then click on service.name
await waitFor(() => {
// Need to find the option-item containing service.name
const serviceNameOption = screen.getByText('service.name');
expect(serviceNameOption).not.toBeNull();
expect(serviceNameOption?.textContent).toBe('service.name');
// Click on the option-item that contains service.name
const optionElement = serviceNameOption?.closest('.option-item');
if (optionElement) {
fireEvent.click(optionElement);
}
});
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
name: 'service.name',
value: 'All Sources',
});
});
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Get the Select component
const select = screen
.getByText('All Sources')
.closest('div[class*="ant-select"]');
expect(select).toBeInTheDocument();
// Directly call the onChange handler by simulating the Select's onChange
// Find the props.onChange of the Select component and call it directly
fireEvent.mouseDown(select as HTMLElement);
// Use a more specific selector to find the "Logs" option
const optionsContainer = document.querySelector(
'.rc-virtual-list-holder-inner',
);
expect(optionsContainer).not.toBeNull();
// Find the option with Logs text content
const logsOption = Array.from(
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
)
.find((element) => element.textContent === 'Logs')
?.closest('.ant-select-item-option');
expect(logsOption).not.toBeNull();
// Click on it
if (logsOption) {
fireEvent.click(logsOption);
}
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
expect.objectContaining({
value: 'Logs',
}),
);
});
it('filters attributes locally when complete is true', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Mock the filter function behavior
const attributeKeys = Object.keys(mockFieldKeysResponse.payload.keys);
// Only "http.status_code" should match the filter
const expectedFilteredKeys = attributeKeys.filter((key) =>
key.includes('http'),
);
// Verify our expected filtering logic
expect(expectedFilteredKeys).toContain('http.status_code');
expect(expectedFilteredKeys).not.toContain('service.name');
expect(expectedFilteredKeys).not.toContain('duration');
// Now verify the component's filtering ability by inputting the search text
const inputElement = screen
.getAllByRole('combobox')[0]
.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: 'http' } });
}
});
it('triggers API call when complete is false and search text changes', async () => {
const mockRefetch = jest.fn();
// Set up the mock to indicate that data is not complete
// and needs to be fetched from the server
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: {
payload: {
keys: {
'http.status_code': [],
},
complete: false, // This indicates server-side filtering is needed
},
},
error: null,
isLoading: false,
refetch: mockRefetch,
});
// Render with Logs as the initial source
render(
<DynamicVariable
{...DEFAULT_PROPS}
dynamicVariablesSelectedValue={{
name: '',
value: 'Logs',
}}
/>,
);
// Clear any initial calls
mockRefetch.mockClear();
// Now test the search functionality
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find the input element and simulate typing
const inputElement = document.querySelector(
'.ant-select-selection-search-input',
);
if (inputElement) {
// Simulate typing in the search input
fireEvent.change(inputElement, { target: { value: 'http' } });
// Verify that the input has the correct value
expect((inputElement as HTMLInputElement).value).toBe('http');
// Wait for the effect to run and verify refetch was called
await waitFor(
() => {
expect(mockRefetch).toHaveBeenCalled();
},
{ timeout: 3000 },
); // Increase timeout to give more time for the effect to run
}
});
it('triggers refetch when attributeSource changes', async () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Clear any initial calls
mockRefetch.mockClear();
// Find and click on the source select to open dropdown
const sourceSelectElement = getSourceSelect();
fireEvent.mouseDown(sourceSelectElement);
// Find and click on the "Metrics" option
const metricsOption = screen.getByText('Metrics');
fireEvent.click(metricsOption);
// Wait for the effect to run
await waitFor(() => {
// Verify that refetch was called after source selection
expect(mockRefetch).toHaveBeenCalled();
});
});
it('shows retry button when error occurs', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: 'Failed to fetch field keys' },
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find and click reload icon (retry button)
const reloadIcon = screen.getByLabelText('reload');
fireEvent.click(reloadIcon);
// Should trigger refetch
expect(mockRefetch).toHaveBeenCalled();
});
});

View File

@@ -100,6 +100,7 @@
.variable-type-btn-group {
display: flex;
width: 342px;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
@@ -198,21 +199,6 @@
}
}
.default-value-section {
display: grid;
grid-template-columns: max-content 1fr;
.default-value-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.variable-textbox-section {
justify-content: space-between;
margin-bottom: 0;

View File

@@ -6,9 +6,7 @@ import { Button, Collapse, Input, Select, Switch, Tag, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import cx from 'classnames';
import Editor from 'components/Editor';
import { CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { map } from 'lodash-es';
@@ -18,20 +16,16 @@ import {
ClipboardType,
DatabaseZap,
LayoutList,
Pyramid,
X,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import {
IDashboardVariable,
TSortVariableValuesType,
TVariableQueryType,
VariableSortTypeArr,
} from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import {
@@ -40,7 +34,6 @@ import {
} from '../../../DashboardVariablesSelection/util';
import { variablePropsToPayloadVariables } from '../../../utils';
import { TVariableMode } from '../types';
import DynamicVariable from './DynamicVariable/DynamicVariable';
import { LabelContainer, VariableItemRow } from './styles';
const { Option } = Select;
@@ -68,7 +61,7 @@ function VariableItem({
variableData.description || '',
);
const [queryType, setQueryType] = useState<TVariableQueryType>(
variableData.type || 'DYNAMIC',
variableData.type || 'QUERY',
);
const [variableQueryValue, setVariableQueryValue] = useState<string>(
variableData.queryValue || '',
@@ -92,53 +85,11 @@ function VariableItem({
variableData.showALLOption || false,
);
const [previewValues, setPreviewValues] = useState<string[]>([]);
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
(variableData.defaultValue as string) || '',
);
const [
dynamicVariablesSelectedValue,
setDynamicVariablesSelectedValue,
] = useState<{ name: string; value: string }>();
useEffect(() => {
if (
variableData.dynamicVariablesAttribute &&
variableData.dynamicVariablesSource
) {
setDynamicVariablesSelectedValue({
name: variableData.dynamicVariablesAttribute,
value: variableData.dynamicVariablesSource,
});
}
}, [
variableData.dynamicVariablesAttribute,
variableData.dynamicVariablesSource,
]);
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorPreview, setErrorPreview] = useState<string | null>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { data: fieldValues } = useGetFieldValues({
signal:
dynamicVariablesSelectedValue?.value === 'All Sources'
? undefined
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
name: dynamicVariablesSelectedValue?.name || '',
enabled:
!!dynamicVariablesSelectedValue?.name &&
!!dynamicVariablesSelectedValue?.value,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
useEffect(() => {
if (queryType === 'CUSTOM') {
setPreviewValues(
@@ -159,29 +110,6 @@ function VariableItem({
variableSortType,
]);
useEffect(() => {
if (
queryType === 'DYNAMIC' &&
fieldValues &&
dynamicVariablesSelectedValue?.name &&
dynamicVariablesSelectedValue?.value
) {
setPreviewValues(
sortValues(
fieldValues.payload?.normalizedValues || [],
variableSortType,
) as never,
);
}
}, [
fieldValues,
variableSortType,
queryType,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
dynamicVariablesSelectedValue,
]);
const handleSave = (): void => {
// Check for cyclic dependencies
const newVariable = {
@@ -198,16 +126,9 @@ function VariableItem({
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,
...(queryType === 'DYNAMIC' && {
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
}),
};
const allVariables = [...Object.values(existingVariables), newVariable];
@@ -337,18 +258,18 @@ function VariableItem({
<div className="variable-type-btn-group">
<Button
type="text"
icon={<Pyramid size={14} />}
icon={<DatabaseZap size={14} />}
className={cx(
// eslint-disable-next-line sonarjs/no-duplicate-string
'variable-type-btn',
queryType === 'DYNAMIC' ? 'selected' : '',
queryType === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('DYNAMIC');
setQueryType('QUERY');
setPreviewValues([]);
}}
>
Dynamic
Query
</Button>
<Button
type="text"
@@ -378,31 +299,8 @@ function VariableItem({
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
// eslint-disable-next-line sonarjs/no-duplicate-string
'variable-type-btn',
queryType === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('QUERY');
setPreviewValues([]);
}}
>
Query
</Button>
</div>
</VariableItemRow>
{queryType === 'DYNAMIC' && (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
/>
</div>
)}
{queryType === 'QUERY' && (
<div className="query-container">
<LabelContainer>
@@ -490,9 +388,7 @@ function VariableItem({
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' ||
queryType === 'CUSTOM' ||
queryType === 'DYNAMIC') && (
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
<>
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
@@ -561,25 +457,6 @@ function VariableItem({
/>
</VariableItemRow>
)}
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{queryType === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={variableDefaultValue}
onChange={(value): void => setVariableDefaultValue(value)}
options={previewValues.map((value) => ({
label: value,
value,
}))}
/>
</VariableItemRow>
</>
)}
</div>

View File

@@ -1,15 +1,14 @@
import { Row } from 'antd';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import './DashboardVariableSelection.styles.scss';
import { Alert, Row } from 'antd';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import {
buildDependencies,
buildDependencyGraph,
@@ -28,8 +27,6 @@ function DashboardVariableSelection(): JSX.Element | null {
setVariablesToGetUpdated,
} = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { data } = selectedDashboard || {};
const { variables } = data || {};
@@ -63,11 +60,8 @@ function DashboardVariableSelection(): JSX.Element | null {
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
}, [getUrlVariables, updateUrlVariable, variables]);
}, [variables]);
useEffect(() => {
if (variablesTableData.length > 0) {
@@ -110,14 +104,12 @@ function DashboardVariableSelection(): JSX.Element | null {
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
// isMountedCall?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
if (id) {
updateLocalStorageDashboardVariables(name, value, allSelected);
updateUrlVariable(id, value, allSelected);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (prev) {
@@ -129,7 +121,6 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
@@ -137,7 +128,6 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
@@ -180,22 +170,22 @@ function DashboardVariableSelection(): JSX.Element | null {
);
return (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
/>
) : (
<>
{dependencyData?.hasCycle && (
<Alert
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
' → ',
)}`}
type="error"
showIcon
className="cycle-error-alert"
/>
)}
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) => (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
@@ -208,9 +198,9 @@ function DashboardVariableSelection(): JSX.Element | null {
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
),
)}
</Row>
))}
</Row>
</>
);
}

View File

@@ -1,385 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import './DashboardVariableSelection.styles.scss';
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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual } from './util';
import { getSelectValue } from './VariableItem';
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 [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// 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,
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
],
{
enabled: variableData.type === 'DYNAMIC',
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
),
onSuccess: (data) => {
setOptionsData(data.payload?.normalizedValues || []);
setIsComplete(data.payload?.complete || false);
setFilteredOptionsData(data.payload?.normalizedValues || []);
},
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);
}
},
},
);
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))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(
variableData.name,
variableData.id,
value,
optionsData.every((v) => value.includes(v.toString())),
Array.isArray(value) &&
!value.every((v) => optionsData.includes(v.toString())),
);
}
}
},
[variableData, onValueUpdate, optionsData],
);
useEffect(() => {
if (
variableData.dynamicVariablesSource &&
variableData.dynamicVariablesAttribute
) {
refetch();
}
}, [
refetch,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
return;
}
const localFilteredOptionsData: (string | number | boolean)[] = [];
optionsData.forEach((option) => {
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
localFilteredOptionsData.push(option);
}
});
setFilteredOptionsData(localFilteredOptionsData);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData],
);
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 = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
const allOptions = [
...new Set([
...optionsData.map((option) => option.toString()),
...(variableData.selectedValue
? Array.isArray(variableData.selectedValue)
? variableData.selectedValue.map((v) => v.toString())
: [variableData.selectedValue.toString()]
: []),
]),
];
setTempSelection(allOptions);
} 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);
setTempSelection(undefined);
}
};
// 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 {
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,
selectedValue,
tempSelection,
optionsData,
]);
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}
</Typography.Text>
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
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 => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || 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={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -8,14 +8,23 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, Popover, Tooltip, Typography } from 'antd';
import {
Checkbox,
Input,
Popover,
Select,
Tag,
Tooltip,
Typography,
} from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { debounce, isArray, isString } from 'lodash-es';
import map from 'lodash-es/map';
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -24,10 +33,17 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
const ALL_SELECT_VALUE = '__ALL__';
enum ToggleTagValue {
Only = 'Only',
All = 'All',
}
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
@@ -42,7 +58,7 @@ interface VariableItemProps {
dependencyData: IDependencyData | null;
}
export const getSelectValue = (
const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
@@ -67,9 +83,6 @@ function VariableItem({
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -133,10 +146,18 @@ function VariableItem({
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
const value = variableData.selectedValue;
let value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
// The default value for multi-select is ALL and first value for
// single select
if (valueNotInList) {
if (variableData.multiSelect) {
value = newOptionsData;
allSelected = true;
} else {
[value] = newOptionsData;
}
} else if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
@@ -221,57 +242,26 @@ function VariableItem({
},
);
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))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
},
[
variableData.multiSelect,
variableData.selectedValue,
variableData.name,
variableData.id,
onValueUpdate,
optionsData,
],
);
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const handleChange = (inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
setTempSelection(undefined);
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
};
@@ -291,58 +281,10 @@ function VariableItem({
? 'ALL'
: selectedValueStringified;
// Apply default value on first render if no selection exists
// 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 {
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,
selectedValue,
tempSelection,
optionsData,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
const mode: 'multiple' | undefined =
variableData.multiSelect && !variableData.allSelected
? 'multiple'
: undefined;
useEffect(() => {
// Fetch options for CUSTOM Type
@@ -352,6 +294,113 @@ function VariableItem({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData.type, variableData.customValue]);
const checkAll = (e: MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
const isChecked =
variableData.allSelected || selectValue?.includes(ALL_SELECT_VALUE);
if (isChecked) {
handleChange([]);
} else {
handleChange(ALL_SELECT_VALUE);
}
};
const handleOptionSelect = (
e: CheckboxChangeEvent,
option: string | number | boolean,
): void => {
const newSelectedValue = Array.isArray(selectedValue)
? ((selectedValue.filter(
(val) => val.toString() !== option.toString(),
) as unknown) as string[])
: [];
if (
!e.target.checked &&
Array.isArray(selectedValueStringified) &&
selectedValueStringified.includes(option.toString())
) {
if (newSelectedValue.length === 1) {
handleChange(newSelectedValue[0].toString());
return;
}
handleChange(newSelectedValue);
} else if (!e.target.checked && selectedValue === option.toString()) {
handleChange(ALL_SELECT_VALUE);
} else if (newSelectedValue.length === optionsData.length - 1) {
handleChange(ALL_SELECT_VALUE);
}
};
const [optionState, setOptionState] = useState({
tag: '',
visible: false,
});
function currentToggleTagValue({
option,
}: {
option: string;
}): ToggleTagValue {
if (
option.toString() === selectValue ||
(Array.isArray(selectValue) &&
selectValue?.includes(option.toString()) &&
selectValue.length === 1)
) {
return ToggleTagValue.All;
}
return ToggleTagValue.Only;
}
function handleToggle(e: ChangeEvent, option: string): void {
e.stopPropagation();
const mode = currentToggleTagValue({ option: option as string });
const isChecked =
variableData.allSelected ||
option.toString() === selectValue ||
(Array.isArray(selectValue) && selectValue?.includes(option.toString()));
if (isChecked) {
if (mode === ToggleTagValue.Only && variableData.multiSelect) {
handleChange([option.toString()]);
} else if (!variableData.multiSelect) {
handleChange(option.toString());
} else {
handleChange(ALL_SELECT_VALUE);
}
} else {
handleChange(option.toString());
}
}
function retProps(
option: string,
): {
onMouseOver: () => void;
onMouseOut: () => void;
} {
return {
onMouseOver: (): void =>
setOptionState({
tag: option.toString(),
visible: true,
}),
onMouseOut: (): void =>
setOptionState({
tag: option.toString(),
visible: false,
}),
};
}
const ensureValidOption = (option: string): boolean =>
!(
currentToggleTagValue({ option }) === ToggleTagValue.All && !enableSelectAll
);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
@@ -379,73 +428,105 @@ function VariableItem({
}}
/>
) : (
optionsData &&
(variableData.multiSelect ? (
<CustomMultiSelect
!errorMessage &&
optionsData && (
<Select
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleTempChange}
defaultValue={selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
mode={mode}
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
maxTagCount={4}
getPopupContainer={popupContainer}
value={tempSelection || selectValue}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
tagRender={(props): JSX.Element => (
<Tag closable onClose={props.onClose}>
{props.value}
</Tag>
)}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={variableData.defaultValue || selectValue}
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={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
errorMessage={errorMessage}
/>
))
>
{enableSelectAll && (
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
<Checkbox checked={variableData.allSelected} />
ALL
</div>
</Select.Option>
)}
{map(optionsData, (option) => (
<Select.Option
data-testid={`option-${option}`}
key={option.toString()}
value={option}
>
<div
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
>
{variableData.multiSelect && (
<Checkbox
onChange={(e): void => {
e.stopPropagation();
e.preventDefault();
handleOptionSelect(e, option);
}}
checked={
variableData.allSelected ||
option.toString() === selectValue ||
(Array.isArray(selectValue) &&
selectValue?.includes(option.toString()))
}
/>
)}
<div
className="dropdown-value"
{...retProps(option as string)}
onClick={(e): void => handleToggle(e as any, option as string)}
>
<Typography.Text
ellipsis={{
tooltip: {
placement: variableData.multiSelect ? 'top' : 'right',
autoAdjustOverflow: true,
},
}}
className="option-text"
>
{option.toString()}
</Typography.Text>
{variableData.multiSelect &&
optionState.tag === option.toString() &&
optionState.visible &&
ensureValidOption(option as string) && (
<Typography.Text className="toggle-tag-label">
{currentToggleTagValue({ option: option as string })}
</Typography.Text>
)}
</div>
</div>
</Select.Option>
))}
</Select>
)
)}
{variableData.type !== 'TEXTBOX' && errorMessage && (
<span style={{ margin: '0 0.5rem' }}>

View File

@@ -1,274 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import * as ReactQuery from 'react-query';
import * as ReactRedux from 'react-redux';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DynamicVariableSelection';
// Don't mock the components - use real ones
// Mock for useQuery
const mockQueryResult = {
data: undefined,
error: null,
isError: false,
isIdle: false,
isLoading: false,
isPreviousData: false,
isSuccess: true,
status: 'success',
isFetched: true,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
isPlaceholderData: false,
isPaused: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isLoadingError: false,
isFetching: false,
isFetchedAfterMount: true,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
refetch: jest.fn(),
remove: jest.fn(),
fetchNextPage: jest.fn(),
fetchPreviousPage: jest.fn(),
hasNextPage: false,
hasPreviousPage: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
// Sample data for testing
const mockApiResponse = {
payload: {
normalizedValues: ['frontend', 'backend', 'database'],
complete: true,
},
statusCode: 200,
};
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableSelection Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
selectedValue: 'frontend',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
};
const mockMultiSelectDynamicVariableData: IDashboardVariable = {
...mockDynamicVariableData,
id: 'var2',
name: 'services',
multiSelect: true,
selectedValue: ['frontend', 'backend'],
showALLOption: true,
};
const mockExistingVariables: Record<string, IDashboardVariable> = {
var1: mockDynamicVariableData,
var2: mockMultiSelectDynamicVariableData,
};
beforeEach(() => {
jest.clearAllMocks();
mockOnValueUpdate.mockClear();
// Mock useSelector
const useSelectorSpy = jest.spyOn(ReactRedux, 'useSelector');
useSelectorSpy.mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
// Mock useQuery with success state
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
useQuerySpy.mockReturnValue({
...mockQueryResult,
data: mockApiResponse,
isLoading: false,
error: null,
});
});
it('renders with single select variable correctly', () => {
render(
<DynamicVariableSelection
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();
// CustomSelect doesn't use the 'mode' attribute for single select
expect(selectedItem).not.toHaveAttribute('mode');
});
it('renders with multi select variable correctly', () => {
// First set up allSelected to true to properly test the ALL display
const multiSelectWithAllSelected = {
...mockMultiSelectDynamicVariableData,
allSelected: true,
};
render(
<DynamicVariableSelection
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();
});
it('shows loading state correctly', () => {
// Mock loading state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: true,
isFetching: true,
isSuccess: false,
status: 'loading',
});
render(
<DynamicVariableSelection
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);
// The loading text should appear in the dropdown
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('handles error state correctly', () => {
const errorMessage = 'Failed to fetch data';
// Mock error state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: false,
isSuccess: false,
isError: true,
status: 'error',
error: { message: errorMessage },
});
render(
<DynamicVariableSelection
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();
// We don't need to check refetch as it might be called during component initialization
});
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the useQuery hook was called with expected parameters
expect(ReactQuery.useQuery).toHaveBeenCalledWith(
[
'DASHBOARD_BY_ID',
mockDynamicVariableData.name,
'service:"frontend"|services:["frontend","backend"]', // The actual dynamicVariablesKey
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
],
expect.objectContaining({
enabled: true, // Type is 'DYNAMIC'
queryFn: expect.any(Function),
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('has the correct selected value', () => {
// Use a different variable configuration to test different behavior
const customVariable = {
...mockDynamicVariableData,
id: 'custom1',
name: 'customService',
selectedValue: 'backend',
};
render(
<DynamicVariableSelection
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();
// Check that the selected value is displayed in the select element
expect(selectElement).toHaveTextContent('backend');
});
});

View File

@@ -14,5 +14,3 @@ export function variablePropsToPayloadVariables(
return payloadVariables;
}
export const ALL_SELECT_VALUE = '__ALL__';

View File

@@ -1,160 +0,0 @@
import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
interface AttributeRecord {
field: string;
value: string;
}
interface AttributeActionsProps {
record: AttributeRecord;
}
export default function AttributeActions({
record,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const {
onAddToQuery,
onGroupByAttribute,
onCopyFieldName,
onCopyFieldValue,
} = useTraceActions();
const textToCopy = useMemo(() => {
const str = record.value == null ? '' : String(record.value);
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
return str.replace(/^"|"$/g, '');
}, [record.value]);
const handleFilterIn = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterInLoading) return;
setIsFilterInLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['=']),
);
} finally {
setIsFilterInLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
const handleFilterOut = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterOutLoading) return;
setIsFilterOutLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['!=']),
);
} finally {
setIsFilterOutLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
const handleGroupBy = useCallback((): void => {
if (onGroupByAttribute) {
onGroupByAttribute(record.field);
}
setIsOpen(false);
}, [onGroupByAttribute, record.field]);
const handleCopyFieldName = useCallback((): void => {
if (onCopyFieldName) {
onCopyFieldName(record.field);
}
setIsOpen(false);
}, [onCopyFieldName, record.field]);
const handleCopyFieldValue = useCallback((): void => {
if (onCopyFieldValue) {
onCopyFieldValue(textToCopy);
}
setIsOpen(false);
}, [onCopyFieldValue, textToCopy]);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupBy}
block
>
Group By Attribute
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className="action-btn">
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}

View File

@@ -24,13 +24,6 @@
flex-direction: column;
gap: 8px;
justify-content: flex-start;
position: relative;
&:hover {
.action-btn {
display: flex;
}
}
.item-key {
color: var(--bg-vanilla-100);
@@ -47,12 +40,11 @@
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: calc(100% - 120px); /* Reserve space for action buttons */
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500);
.item-value {
color: var(--bg-vanilla-400);
font-family: Inter;
@@ -63,35 +55,6 @@
letter-spacing: 0.56px;
}
}
.action-btn {
display: none;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
padding: 2px;
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-400);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
}
}
@@ -100,36 +63,6 @@
}
}
.attribute-actions-menu {
display: flex;
flex-direction: column;
gap: 4px;
.ant-btn {
text-align: left;
height: auto;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--bg-slate-400);
}
}
.group-by-clause {
color: var(--text-primary);
}
}
.attribute-actions-content {
.ant-popover-inner {
padding: 8px;
min-width: 160px;
}
}
.lightMode {
.attributes-corner {
.attributes-container {
@@ -146,18 +79,6 @@
color: var(--bg-ink-400);
}
}
.action-btn {
background: rgba(255, 255, 255, 0.9);
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
}
}
@@ -165,12 +86,4 @@
border-top: 1px solid var(--bg-vanilla-300);
}
}
.attribute-actions-menu {
.ant-btn {
&:hover {
background-color: var(--bg-vanilla-200);
}
}
}
}

View File

@@ -2,13 +2,11 @@ import './Attributes.styles.scss';
import { Input, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { flattenObject } from 'container/LogDetailedView/utils';
import { useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
import AttributeActions from './AttributeActions';
interface IAttributesProps {
span: Span;
@@ -55,13 +53,10 @@ function Attributes(props: IAttributesProps): JSX.Element {
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.value}>
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
</CopyClipboardHOC>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
</Tooltip>
<AttributeActions record={item} />
</div>
</div>
))}

View File

@@ -1,169 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import useVariablesFromUrl from '../useVariablesFromUrl';
describe('useVariablesFromUrl', () => {
it('should initialize with empty variables when no URL params exist', () => {
const history = createMemoryHistory({
initialEntries: ['/'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual({});
});
it('should correctly parse variables from URL', () => {
const mockVariables = {
var1: { selectedValue: 'value1', allSelected: false },
var2: { selectedValue: ['value2', 'value3'], allSelected: true },
};
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variableConfigs}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual(mockVariables);
});
it('should handle malformed URL parameters gracefully', () => {
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variableConfigs}=invalid-json`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
// Should return empty object when JSON parsing fails
expect(result.current.getUrlVariables()).toEqual({});
});
it('should set variables to URL correctly', () => {
const history = createMemoryHistory({
initialEntries: ['/'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const mockVariables = {
var1: { selectedValue: 'value1', allSelected: false },
};
act(() => {
result.current.setUrlVariables(mockVariables);
});
// Check if the URL was updated correctly
const searchParams = new URLSearchParams(history.location.search);
const urlVariables = searchParams.get(QueryParams.variableConfigs);
expect(urlVariables).toBeTruthy();
expect(JSON.parse(decodeURIComponent(urlVariables || ''))).toEqual(
mockVariables,
);
});
it('should remove variables param from URL when empty object is provided', () => {
const mockVariables = {
var1: { selectedValue: 'value1', allSelected: false },
};
const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variableConfigs}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
act(() => {
result.current.setUrlVariables({});
});
// Check if the URL param was removed
const searchParams = new URLSearchParams(history.location.search);
expect(searchParams.has(QueryParams.variableConfigs)).toBe(false);
});
it('should update a specific variable correctly', () => {
const initialVariables = {
var1: { selectedValue: 'value1', allSelected: false },
var2: { selectedValue: ['value2'], allSelected: true },
};
const encodedVariables = encodeURIComponent(JSON.stringify(initialVariables));
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variableConfigs}=${encodedVariables}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const newValue: IDashboardVariable['selectedValue'] = 'updated-value';
act(() => {
result.current.updateUrlVariable('var1', newValue, true);
});
// Check if only the specified variable was updated
const updatedVariables = result.current.getUrlVariables();
expect(updatedVariables.var1).toEqual({
selectedValue: newValue,
allSelected: true,
});
expect(updatedVariables.var2).toEqual(initialVariables.var2);
});
it('should preserve other URL parameters when updating variables', () => {
const history = createMemoryHistory({
initialEntries: ['/?otherParam=value'],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
const mockVariables = {
var1: { selectedValue: 'value1', allSelected: false },
};
act(() => {
result.current.setUrlVariables(mockVariables);
});
// Check if other params are preserved
const searchParams = new URLSearchParams(history.location.search);
expect(searchParams.get('otherParam')).toBe('value');
expect(searchParams.has(QueryParams.variableConfigs)).toBe(true);
});
});

View File

@@ -1,102 +0,0 @@
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
interface LocalStoreDashboardVariables {
[id: string]: {
selectedValue: IDashboardVariable['selectedValue'];
allSelected: boolean;
};
}
interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
) => void;
clearUrlVariables: () => void;
}
const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
const urlQuery = useUrlQuery();
const history = useHistory();
const getUrlVariables = useCallback((): LocalStoreDashboardVariables => {
const variableConfigsParam = urlQuery.get(QueryParams.variableConfigs);
if (!variableConfigsParam) {
return {};
}
try {
return JSON.parse(decodeURIComponent(variableConfigsParam));
} catch (error) {
console.error('Failed to parse variables from URL:', error);
return {};
}
}, [urlQuery]);
const setUrlVariables = useCallback(
(variables: LocalStoreDashboardVariables): void => {
const params = new URLSearchParams(urlQuery.toString());
if (Object.keys(variables).length === 0) {
params.delete(QueryParams.variableConfigs);
} else {
try {
const encodedVariables = encodeURIComponent(JSON.stringify(variables));
params.set(QueryParams.variableConfigs, encodedVariables);
} catch (error) {
console.error('Failed to serialize variables for URL:', error);
}
}
history.replace({
search: params.toString(),
});
},
[history, urlQuery],
);
const clearUrlVariables = useCallback((): void => {
const params = new URLSearchParams(urlQuery.toString());
params.delete(QueryParams.variableConfigs);
params.delete('options');
history.replace({
search: params.toString(),
});
}, [history, urlQuery]);
const updateUrlVariable = useCallback(
(
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
): void => {
const currentVariables = getUrlVariables();
const updatedVariables = {
...currentVariables,
[id]: { selectedValue, allSelected },
};
setUrlVariables(updatedVariables);
},
[getUrlVariables, setUrlVariables],
);
return {
getUrlVariables,
setUrlVariables,
updateUrlVariable,
clearUrlVariables,
};
};
export default useVariablesFromUrl;

View File

@@ -1,35 +0,0 @@
import { getFieldKeys } from 'api/dynamicVariables/getFieldKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
interface UseGetFieldKeysProps {
/** Type of signal (traces, logs, metrics) */
signal?: 'traces' | 'logs' | 'metrics';
/** Optional search text */
name?: string;
/** Whether the query should be enabled */
enabled?: boolean;
}
/**
* Hook to fetch field keys for a given signal type
*
* If 'complete' in the response is true:
* - All subsequent searches should be local (client has complete list)
*
* If 'complete' is false:
* - All subsequent searches should use the API (passing the name param)
*/
export const useGetFieldKeys = ({
signal,
name,
enabled = true,
}: UseGetFieldKeysProps): UseQueryResult<
SuccessResponse<FieldKeyResponse> | ErrorResponse
> =>
useQuery<SuccessResponse<FieldKeyResponse> | ErrorResponse>({
queryKey: ['fieldKeys', signal, name],
queryFn: () => getFieldKeys(signal, name),
enabled,
});

View File

@@ -1,45 +0,0 @@
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
interface UseGetFieldValuesProps {
/** Type of signal (traces, logs, metrics) */
signal?: 'traces' | 'logs' | 'metrics';
/** Name of the attribute for which values are being fetched */
name: string;
/** Optional search text */
value?: string;
/** Whether the query should be enabled */
enabled?: boolean;
/** Start Unix Milli */
startUnixMilli?: number;
/** End Unix Milli */
endUnixMilli?: number;
}
/**
* Hook to fetch field values for a given signal type and field name
*
* If 'complete' in the response is true:
* - All subsequent searches should be local (client has complete list)
*
* If 'complete' is false:
* - All subsequent searches should use the API (passing the value param)
*/
export const useGetFieldValues = ({
signal,
name,
value,
startUnixMilli,
endUnixMilli,
enabled = true,
}: UseGetFieldValuesProps): UseQueryResult<
SuccessResponse<FieldValueResponse> | ErrorResponse
> =>
useQuery<SuccessResponse<FieldValueResponse> | ErrorResponse>({
queryKey: ['fieldValues', signal, name, value, startUnixMilli, endUnixMilli],
queryFn: () =>
getFieldValues(signal, name, value, startUnixMilli, endUnixMilli),
enabled,
});

View File

@@ -1,193 +0,0 @@
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export interface UseTraceActionsReturn {
onAddToQuery: (
fieldKey: string,
fieldValue: string,
operator: string,
) => Promise<void>;
onGroupByAttribute: (fieldKey: string) => Promise<void>;
onCopyFieldName: (fieldName: string) => void;
onCopyFieldValue: (fieldValue: string) => void;
}
export const useTraceActions = (): UseTraceActionsReturn => {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const queryClient = useQueryClient();
const { notifications } = useNotifications();
const [, setCopy] = useCopyToClipboard();
const removeExistingFieldFilters = useCallback(
(filters: TagFilterItem[], fieldKey: BaseAutocompleteData): TagFilterItem[] =>
filters.filter((filter: TagFilterItem) => filter.key?.key !== fieldKey.key),
[],
);
const getAutocompleteKey = useCallback(
async (fieldKey: string): Promise<BaseAutocompleteData> => {
const keysAutocompleteResponse = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: DataSource.TRACES,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
const keysAutocomplete: BaseAutocompleteData[] =
keysAutocompleteResponse.payload?.attributeKeys || [];
return chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
false,
DataTypes.String,
);
},
[queryClient, currentQuery.builder.queryData],
);
const onAddToQuery = useCallback(
async (
fieldKey: string,
fieldValue: string,
operator: string,
): Promise<void> => {
try {
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
const currentOperator = getOperatorValue(operator);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => {
// Get existing filters and remove any for the same field
const currentFilters = item.filters?.items || [];
const cleanedFilters = removeExistingFieldFilters(
currentFilters,
existAutocompleteKey,
);
// Add the new filter to the cleaned list
const newFilters = [
...cleanedFilters,
{
id: uuid(),
key: existAutocompleteKey,
op: currentOperator,
value: fieldValue,
},
];
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
{
items: newFilters,
op: item.filters?.op || 'AND',
},
item.filter?.expression || '',
);
return {
...item,
dataSource: DataSource.TRACES,
filters: convertedFilter.filters,
filter: convertedFilter.filter,
};
}),
},
};
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
notifications,
getAutocompleteKey,
redirectWithQueryBuilderData,
removeExistingFieldFilters,
],
);
const onGroupByAttribute = useCallback(
async (fieldKey: string): Promise<void> => {
try {
const existAutocompleteKey = await getAutocompleteKey(fieldKey);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
dataSource: DataSource.TRACES,
groupBy: [...item.groupBy, existAutocompleteKey],
})),
},
};
redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[
currentQuery,
notifications,
getAutocompleteKey,
redirectWithQueryBuilderData,
],
);
const onCopyFieldName = useCallback(
(fieldName: string): void => {
setCopy(fieldName);
notifications.success({
message: 'Field name copied to clipboard',
});
},
[setCopy, notifications],
);
const onCopyFieldValue = useCallback(
(fieldValue: string): void => {
setCopy(fieldValue);
notifications.success({
message: 'Field value copied to clipboard',
});
},
[setCopy, notifications],
);
return {
onAddToQuery,
onGroupByAttribute,
onCopyFieldName,
onCopyFieldValue,
};
};

View File

@@ -23,12 +23,7 @@ export const getDashboardVariables = (
Object.entries(variables).forEach(([, value]) => {
if (value?.name) {
variablesTuple[value.name] =
value?.type === 'DYNAMIC' &&
value?.allSelected &&
!value?.haveCustomValuesSelected
? '__all__'
: value?.selectedValue;
variablesTuple[value.name] = value?.selectedValue;
}
});

View File

@@ -7,7 +7,6 @@ import ROUTES from 'constants/routes';
import { getMinMax } from 'container/TopNav/AutoRefresh/config';
import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useTabVisibility from 'hooks/useTabFocus';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -17,7 +16,6 @@ import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined';
import omitBy from 'lodash-es/omitBy';
import { useAppContext } from 'providers/App/App';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { useErrorModal } from 'providers/ErrorModalProvider';
import {
createContext,
@@ -200,12 +198,6 @@ export function DashboardProvider({
updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId);
const {
getUrlVariables,
updateUrlVariable,
clearUrlVariables,
} = useVariablesFromUrl();
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
const modalRef = useRef<any>(null);
@@ -216,14 +208,6 @@ export function DashboardProvider({
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
// Clear variable configs when not on dashboard pages
useEffect(() => {
const isOnDashboardPage = !!isDashboardPage || !!isDashboardWidgetPage;
if (!isOnDashboardPage) {
clearUrlVariables();
}
}, [isDashboardPage, isDashboardWidgetPage, clearUrlVariables]);
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
@@ -233,23 +217,11 @@ export function DashboardProvider({
const updatedVariables = data.data.variables;
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = getUrlVariables()[variableData.id];
let updatedVariable = {
const updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (urlVariable) {
updatedVariable = {
...updatedVariable,
...urlVariable,
};
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
@@ -308,7 +280,7 @@ export function DashboardProvider({
});
} catch (error) {
showErrorModal(error as APIError);
throw error;
return;
} finally {
setIsDashboardFetching(false);
}
@@ -317,17 +289,9 @@ export function DashboardProvider({
onError: (error) => {
showErrorModal(error as APIError);
},
// eslint-disable-next-line sonarjs/cognitive-complexity
onSuccess: (data) => {
// if the url variable is not set for any variable, set it to the default value
const variables = data?.data.data?.variables;
if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
if (!data?.data) return;
const updatedDashboardData = transformDashboardVariables(data.data);
const updatedDate = dayjs(updatedDashboardData.updatedAt);
onSuccess: (data: SuccessResponseV2<Dashboard>) => {
const updatedDashboardData = transformDashboardVariables(data?.data);
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
setIsDashboardLocked(updatedDashboardData?.locked || false);

View File

@@ -1,46 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { commaValuesParser } from '../../lib/dashbaordVariables/customCommaValuesParser';
interface UrlVariables {
[key: string]: any;
}
/**
* Initializes default values for dashboard variables if not already in URL
* Handles cases where variables might be keyed by either id or name
*
* @param variables Dashboard variables object
* @param getUrlVariables Function to get variables from URL
* @param updateUrlVariable Function to update URL with variable values
*/
export const initializeDefaultVariables = (
variables: Record<string, IDashboardVariable>,
getUrlVariables: () => UrlVariables | undefined,
updateUrlVariable: (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
) => void,
): void => {
if (!variables) return;
Object.values(variables).forEach((variable) => {
const { id, name } = variable;
const urlVariables = getUrlVariables();
// Check if either id or name is available in URL variables
const existsInUrl =
(id && urlVariables?.[id]) || (name && urlVariables?.[name]);
if (!existsInUrl) {
updateUrlVariable(
id,
variable.type === 'CUSTOM'
? commaValuesParser(variable?.customValue || '')
: variable?.selectedValue || variable?.defaultValue,
variable.allSelected || false,
);
}
});
};

View File

@@ -61,7 +61,6 @@ import {
QueryBuilderData,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
@@ -103,12 +102,6 @@ export function QueryBuilderProvider({
const currentPathnameRef = useRef<string | null>(location.pathname);
// This is used to determine if the query was called from the handleRunQuery function - which means manual trigger from Stage and Run button
const [
calledFromHandleRunQuery,
setCalledFromHandleRunQuery,
] = useState<boolean>(false);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -191,17 +184,6 @@ export function QueryBuilderProvider({
} as BaseAutocompleteData,
};
// Explorer pages: sanitize stale orderBy before first query
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
if (isExplorer) {
const sanitizedOrderBy = sanitizeOrderByForExplorer(currentElement);
return calledFromHandleRunQuery
? currentElement
: { ...currentElement, orderBy: sanitizedOrderBy };
}
return currentElement;
});
@@ -233,7 +215,7 @@ export function QueryBuilderProvider({
return nextQuery;
},
[initialDataSource, location.pathname, calledFromHandleRunQuery],
[initialDataSource],
);
const initQueryBuilderData = useCallback(
@@ -446,7 +428,6 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({
existNames,
@@ -541,8 +522,6 @@ export function QueryBuilderProvider({
setCurrentQuery((prevState) => {
if (prevState.builder.queryData.length >= MAX_QUERIES) return prevState;
console.log('prevState', prevState.builder.queryData);
const newQuery = createNewBuilderQuery(prevState.builder.queryData);
return {
@@ -553,7 +532,6 @@ export function QueryBuilderProvider({
},
};
});
// eslint-disable-next-line sonarjs/no-identical-functions
setSupersetQuery((prevState) => {
if (prevState.builder.queryData.length >= MAX_QUERIES) return prevState;
@@ -889,12 +867,6 @@ export function QueryBuilderProvider({
const handleRunQuery = useCallback(
(shallUpdateStepInterval?: boolean, newQBQuery?: boolean) => {
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}
let currentQueryData = currentQuery;
if (newQBQuery) {
@@ -939,14 +911,7 @@ export function QueryBuilderProvider({
queryType,
});
},
[
location.pathname,
currentQuery,
queryType,
maxTime,
minTime,
redirectWithQueryBuilderData,
],
[currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData],
);
useEffect(() => {
@@ -956,7 +921,6 @@ export function QueryBuilderProvider({
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
setCalledFromHandleRunQuery(false);
}
}, [location.pathname]);

View File

@@ -9,12 +9,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IField } from '../logs/fields';
import { TelemetryFieldKey } from '../v5/queryRange';
export const VariableQueryTypeArr = [
'QUERY',
'TEXTBOX',
'CUSTOM',
'DYNAMIC',
] as const;
export const VariableQueryTypeArr = ['QUERY', 'TEXTBOX', 'CUSTOM'] as const;
export type TVariableQueryType = typeof VariableQueryTypeArr[number];
export const VariableSortTypeArr = ['DISABLED', 'ASC', 'DESC'] as const;
@@ -51,10 +46,6 @@ export interface IDashboardVariable {
modificationUUID?: string;
allSelected?: boolean;
change?: boolean;
defaultValue?: string;
dynamicVariablesAttribute?: string;
dynamicVariablesSource?: string;
haveCustomValuesSelected?: boolean;
}
export interface Dashboard {
id: string;

View File

@@ -1,23 +0,0 @@
/**
* Response from the field keys API
*/
export interface FieldKeyResponse {
/** List of field keys returned */
keys?: Record<string, FieldKey[]>;
/** Indicates if the returned list is complete */
complete?: boolean;
}
/**
* Field key data structure
*/
export interface FieldKey {
/** Key name */
name?: string;
/** Data type of the field */
fieldDataType?: string;
/** Signal type */
signal?: string;
/** Field context */
fieldContext?: string;
}

View File

@@ -1,11 +0,0 @@
/**
* Response from the field values API
*/
export interface FieldValueResponse {
/** List of field values returned by type */
values: Record<string, (string | boolean | number)[]>;
/** Normalized values combined from all types */
normalizedValues?: string[];
/** Indicates if the returned list is complete */
complete: boolean;
}

View File

@@ -1,130 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
OrderByPayload,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
jest.mock('utils/aggregationConverter', () => ({
getParsedAggregationOptionsForOrderBy: jest.fn(),
}));
const buildQuery = (overrides: Partial<IBuilderQuery> = {}): IBuilderQuery => ({
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: '',
aggregateAttribute: undefined,
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: { expression: '' } as any,
filters: { items: [], op: 'AND' } as any,
groupBy: [],
expression: '',
disabled: false,
having: [] as any,
limit: null,
stepInterval: 60 as any,
orderBy: [],
legend: '',
...overrides,
});
describe('sanitizeOrderByForExplorer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('keeps only orderBy items that are present in groupBy keys or aggregation keys (including alias)', () => {
(getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([
{
key: 'count()',
dataType: DataTypes.Float64,
isColumn: false,
type: '',
isJSON: false,
},
{
key: 'avg(duration)',
dataType: DataTypes.Float64,
isColumn: false,
type: '',
isJSON: false,
},
{
key: 'latency',
dataType: DataTypes.Float64,
isColumn: false,
type: '',
isJSON: false,
},
]);
const orderBy: OrderByPayload[] = [
{ columnName: 'service.name', order: 'asc' },
{ columnName: 'count()', order: 'desc' },
{ columnName: 'avg(duration)', order: 'asc' },
{ columnName: 'latency', order: 'asc' }, // alias
{ columnName: 'not-allowed', order: 'desc' }, // invalid orderBy
{ columnName: 'timestamp', order: 'desc' }, // invalid orderBy
];
const query = buildQuery({
groupBy: [
{
key: 'service.name',
dataType: DataTypes.String,
isColumn: true,
type: 'resource',
isJSON: false,
},
] as any,
orderBy,
});
const result = sanitizeOrderByForExplorer(query);
expect(result).toEqual([
{ columnName: 'service.name', order: 'asc' },
{ columnName: 'count()', order: 'desc' },
{ columnName: 'avg(duration)', order: 'asc' },
{ columnName: 'latency', order: 'asc' },
]);
});
it('returns empty when none of the orderBy items are allowed', () => {
(getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([
{
key: 'count()',
dataType: DataTypes.Float64,
isColumn: false,
type: '',
isJSON: false,
},
]);
const query = buildQuery({
groupBy: [],
orderBy: [
{ columnName: 'foo', order: 'asc' },
{ columnName: 'bar', order: 'desc' },
],
});
const result = sanitizeOrderByForExplorer(query);
expect(result).toEqual([]);
});
it('handles missing orderBy by returning an empty array', () => {
(getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([]);
const query = buildQuery({ orderBy: [] });
const result = sanitizeOrderByForExplorer(query);
expect(result).toEqual([]);
});
});

View File

@@ -1,32 +0,0 @@
import * as Sentry from '@sentry/react';
import {
IBuilderQuery,
OrderByPayload,
} from 'types/api/queryBuilder/queryBuilderData';
import { getParsedAggregationOptionsForOrderBy } from './aggregationConverter';
export function sanitizeOrderByForExplorer(
query: IBuilderQuery,
): OrderByPayload[] {
const allowed = new Set<string>();
(query.groupBy || []).forEach((g) => g?.key && allowed.add(g.key));
getParsedAggregationOptionsForOrderBy(query).forEach((agg) => {
// agg.key is the expression or alias (e.g., count(), avg(quantity), 'alias')
if ((agg as any)?.key) allowed.add((agg as any).key as string);
});
const current = query.orderBy || [];
const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName));
if (hasInvalidOrderBy) {
Sentry.captureEvent({
message: `Invalid orderBy: current: ${JSON.stringify(
current,
)} - allowed: ${JSON.stringify(Array.from(allowed))}`,
level: 'warning',
});
}
return current.filter((o) => allowed.has(o.columnName));
}

View File

@@ -100,36 +100,16 @@ export function isFunctionOperator(operator: string): boolean {
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
const sanitizedOperator = operator.trim();
// Check if it's a direct function operator (case-insensitive)
if (
functionOperators.some(
(func) => func.toLowerCase() === sanitizedOperator.toLowerCase(),
)
) {
// Check if it's a direct function operator
if (functionOperators.includes(sanitizedOperator)) {
return true;
}
// Check if it's a NOT function operator (e.g., "NOT has")
if (sanitizedOperator.toUpperCase().startsWith(OPERATORS.NOT)) {
const operatorWithoutNot = sanitizedOperator.substring(4).toLowerCase();
return functionOperators.some(
(func) => func.toLowerCase() === operatorWithoutNot,
);
return functionOperators.includes(operatorWithoutNot);
}
return false;
}
export function isNonValueOperator(operator: string): boolean {
const upperOperator = operator.toUpperCase();
// Check if it's a direct non-value operator
if (NON_VALUE_OPERATORS.includes(upperOperator)) {
return true;
}
// Check if it's a NOT non-value operator (e.g., "NOT EXISTS")
if (upperOperator.startsWith(OPERATORS.NOT)) {
const operatorWithoutNot = upperOperator.substring(4).trim(); // Remove "NOT " prefix
return NON_VALUE_OPERATORS.includes(operatorWithoutNot);
}
return false;
}

View File

@@ -490,6 +490,7 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac
key string
}
seriesMap := make(map[seriesKey]*qbtypes.TimeSeries, estimatedSeries)
var queryName string
for _, bucket := range buckets {
var tsData *qbtypes.TimeSeriesData
@@ -498,6 +499,11 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac
continue
}
// Preserve the query name from the first bucket
if queryName == "" && tsData.QueryName != "" {
queryName = tsData.QueryName
}
for _, aggBucket := range tsData.Aggregations {
for _, series := range aggBucket.Series {
// Create series key from labels
@@ -543,6 +549,7 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac
// Convert map back to slice
result := &qbtypes.TimeSeriesData{
QueryName: queryName,
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(aggMap)),
}
@@ -731,7 +738,9 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
case qbtypes.RequestTypeTimeSeries:
// Trim time series data
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
trimmedData := &qbtypes.TimeSeriesData{}
trimmedData := &qbtypes.TimeSeriesData{
QueryName: tsData.QueryName,
}
for _, aggBucket := range tsData.Aggregations {
trimmedBucket := &qbtypes.AggregationBucket{
@@ -798,6 +807,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
case qbtypes.RequestTypeTimeSeries:
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
filteredData := &qbtypes.TimeSeriesData{
QueryName: tsData.QueryName,
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
}

View File

@@ -169,8 +169,9 @@ func TestBucketCache_Put_And_Get(t *testing.T) {
assert.Equal(t, []string{"test warning"}, cached.Warnings)
// Verify the time series data
_, ok := cached.Value.(*qbtypes.TimeSeriesData)
tsData, ok := cached.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)
assert.Equal(t, "A", tsData.QueryName)
}
func TestBucketCache_PartialHit(t *testing.T) {
@@ -1076,6 +1077,7 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
// Verify the cached result only contains values within the requested range
tsData, ok := cached.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)
assert.Equal(t, "A", tsData.QueryName)
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)

View File

@@ -385,15 +385,6 @@ func (q *querier) run(
if err != nil {
return nil, err
}
switch v := result.Value.(type) {
case *qbtypes.TimeSeriesData:
v.QueryName = name
case *qbtypes.ScalarData:
v.QueryName = name
case *qbtypes.RawData:
v.QueryName = name
}
results[name] = result.Value
warnings = append(warnings, result.Warnings...)
warningsDocURL = result.WarningsDocURL

View File

@@ -43,17 +43,17 @@ var (
// FromUnit returns a converter for the given unit
func FromUnit(u Unit) Converter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min":
case "ns", "us", "µs", "ms", "s", "m", "h", "d":
return DurationConverter
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy":
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes":
return DataConverter
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s":
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits":
return DataRateConverter
case "percent", "percentunit", "%":
case "percent", "percentunit":
return PercentConverter
case "bool", "bool_yes_no", "bool_true_false", "bool_1_0":
return BoolConverter
case "cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "{count}/s", "{ops}/s", "{req}/s", "{read}/s", "{write}/s", "{iops}/s", "{count}/min", "{ops}/min", "{read}/min", "{write}/min":
case "cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm":
return ThroughputConverter
default:
return NoneConverter

View File

@@ -60,7 +60,7 @@ func (*dataConverter) Name() string {
func FromDataUnit(u Unit) float64 {
switch u {
case "bytes", "By": // base 2
case "bytes": // base 2
return Byte
case "decbytes": // base 10
return Byte
@@ -68,23 +68,23 @@ func FromDataUnit(u Unit) float64 {
return Bit
case "decbits": // base 10
return Bit
case "kbytes", "kBy": // base 2
case "kbytes": // base 2
return Kibibyte
case "decKbytes", "deckbytes": // base 10
return Kilobyte
case "mbytes", "MBy": // base 2
case "mbytes": // base 2
return Mebibyte
case "decMbytes", "decmbytes": // base 10
return Megabyte
case "gbytes", "GBy": // base 2
case "gbytes": // base 2
return Gibibyte
case "decGbytes", "decgbytes": // base 10
return Gigabyte
case "tbytes", "TBy": // base 2
case "tbytes": // base 2
return Tebibyte
case "decTbytes", "dectbytes": // base 10
return Terabyte
case "pbytes", "PBy": // base 2
case "pbytes": // base 2
return Pebibyte
case "decPbytes", "decpbytes": // base 10
return Petabyte

View File

@@ -59,51 +59,51 @@ func FromDataRateUnit(u Unit) float64 {
switch u {
case "binBps": // bytes/sec(IEC)
return BytePerSecond
case "Bps", "By/s": // bytes/sec(SI)
case "Bps": // bytes/sec(SI)
return BytePerSecond
case "binbps": // bits/sec(IEC)
return BitPerSecond
case "bps", "bit/s": // bits/sec(SI)
case "bps": // bits/sec(SI)
return BitPerSecond
case "KiBs": // kibibytes/sec
return KibibytePerSecond
case "Kibits": // kibibits/sec
return KibibitPerSecond
case "KBs", "kBy/s": // kilobytes/sec
case "KBs": // kilobytes/sec
return KilobytePerSecond
case "Kbits", "kbit/s": // kilobits/sec
case "Kbits": // kilobits/sec
return KilobitPerSecond
case "MiBs": // mebibytes/sec
return MebibytePerSecond
case "Mibits": // mebibits/sec
return MebibitPerSecond
case "MBs", "MBy/s": // megabytes/sec
case "MBs": // megabytes/sec
return MegabytePerSecond
case "Mbits", "Mbit/s": // megabits/sec
case "Mbits": // megabits/sec
return MegabitPerSecond
case "GiBs": // gibibytes/sec
return GibibytePerSecond
case "Gibits": // gibibits/sec
return GibibitPerSecond
case "GBs", "GBy/s": // gigabytes/sec
case "GBs": // gigabytes/sec
return GigabytePerSecond
case "Gbits", "Gbit/s": // gigabits/sec
case "Gbits": // gigabits/sec
return GigabitPerSecond
case "TiBs": // tebibytes/sec
return TebibytePerSecond
case "Tibits": // tebibits/sec
return TebibitPerSecond
case "TBs", "TBy/s": // terabytes/sec
case "TBs": // terabytes/sec
return TerabytePerSecond
case "Tbits", "Tbit/s": // terabits/sec
case "Tbits": // terabits/sec
return TerabitPerSecond
case "PiBs": // pebibytes/sec
return PebibytePerSecond
case "Pibits": // pebibits/sec
return PebibitPerSecond
case "PBs", "PBy/s": // petabytes/sec
case "PBs": // petabytes/sec
return PetabytePerSecond
case "Pbits", "Pbit/s": // petabits/sec
case "Pbits": // petabits/sec
return PetabitPerSecond
default:
return 1

View File

@@ -36,16 +36,10 @@ func TestDataRate(t *testing.T) {
// 8 bits = 1 byte
assert.Equal(t, Value{F: 1, U: "binBps"}, dataRateConverter.Convert(Value{F: 8, U: "binbps"}, "binBps"))
// 8 bits = 1 byte
assert.Equal(t, Value{F: 1, U: "Bps"}, dataRateConverter.Convert(Value{F: 8, U: "bps"}, "Bps"))
// 8 bits = 1 byte
assert.Equal(t, Value{F: 1, U: "By/s"}, dataRateConverter.Convert(Value{F: 8, U: "bit/s"}, "By/s"))
// 1024 bytes = 1 kbytes
assert.Equal(t, Value{F: 1, U: "KiBs"}, dataRateConverter.Convert(Value{F: 1024, U: "binBps"}, "KiBs"))
// 1 byte = 8 bits
assert.Equal(t, Value{F: 8, U: "binbps"}, dataRateConverter.Convert(Value{F: 1, U: "binBps"}, "binbps"))
// 1 byte = 8 bits
assert.Equal(t, Value{F: 8, U: "bit/s"}, dataRateConverter.Convert(Value{F: 1, U: "Bps"}, "bit/s"))
// 1 mbytes = 1024 kbytes
assert.Equal(t, Value{F: 1, U: "MiBs"}, dataRateConverter.Convert(Value{F: 1024, U: "KiBs"}, "MiBs"))
// 1 kbytes = 1024 bytes
@@ -63,10 +57,6 @@ func TestDataRate(t *testing.T) {
// 1 gbytes = 1024 * 1024 kbytes
assert.Equal(t, Value{F: 1024 * 1024, U: "KiBs"}, dataRateConverter.Convert(Value{F: 1, U: "GiBs"}, "KiBs"))
// 1 gbytes = 1024 * 1024 * 1024 bytes
assert.Equal(t, Value{F: (1024 * 1024 * 1024 * 8) / 1024, U: "Kibits"}, dataRateConverter.Convert(Value{F: 1, U: "GiBs"}, "Kibits"))
// 1 gbytes = 1024 * 1024 * 1024 bytes
assert.Equal(t, Value{F: float64(1024*1024*1024) / 1000.0, U: "kBy/s"}, dataRateConverter.Convert(Value{F: 1, U: "GiBs"}, "kBy/s"))
// 1 gbytes = 1024 * 1024 * 1024 bytes
assert.Equal(t, Value{F: 1024 * 1024 * 1024, U: "binBps"}, dataRateConverter.Convert(Value{F: 1, U: "GiBs"}, "binBps"))
// 1024 * 1024 bytes = 1 mbytes
assert.Equal(t, Value{F: 1, U: "MiBs"}, dataRateConverter.Convert(Value{F: 1024 * 1024, U: "binBps"}, "MiBs"))

View File

@@ -10,10 +10,8 @@ func TestData(t *testing.T) {
dataConverter := NewDataConverter()
// 8 bits = 1 byte
assert.Equal(t, Value{F: 1, U: "bytes"}, dataConverter.Convert(Value{F: 8, U: "bits"}, "bytes"))
assert.Equal(t, Value{F: 1, U: "By"}, dataConverter.Convert(Value{F: 8, U: "bits"}, "By"))
// 1024 bytes = 1 kbytes
assert.Equal(t, Value{F: 1, U: "kbytes"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kbytes"))
assert.Equal(t, Value{F: 1, U: "kBy"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kBy"))
// 1 byte = 8 bits
assert.Equal(t, Value{F: 8, U: "bits"}, dataConverter.Convert(Value{F: 1, U: "bytes"}, "bits"))
// 1 mbytes = 1024 kbytes
@@ -22,7 +20,6 @@ func TestData(t *testing.T) {
assert.Equal(t, Value{F: 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "kbytes"}, "bytes"))
// 1024 kbytes = 1 mbytes
assert.Equal(t, Value{F: 1, U: "mbytes"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "mbytes"))
assert.Equal(t, Value{F: 1, U: "MBy"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "MBy"))
// 1 mbytes = 1024 * 1024 bytes
assert.Equal(t, Value{F: 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "mbytes"}, "bytes"))
// 1024 mbytes = 1 gbytes
@@ -45,10 +42,6 @@ func TestData(t *testing.T) {
assert.Equal(t, Value{F: 1024 * 1024 * 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "tbytes"}, "bytes"))
// 1024 tbytes = 1 pbytes
assert.Equal(t, Value{F: 1, U: "pbytes"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "pbytes"))
// 1024 tbytes = 1 pbytes
assert.Equal(t, Value{F: 1, U: "PBy"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "PBy"))
// 1 pbytes = 1024 tbytes
assert.Equal(t, Value{F: 1024, U: "tbytes"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "tbytes"))
// 1024 pbytes = 1 tbytes
assert.Equal(t, Value{F: 1024, U: "TBy"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "TBy"))
}

View File

@@ -13,7 +13,7 @@ func (*percentConverter) Name() string {
func FromPercentUnit(u Unit) float64 {
switch u {
case "percent", "%":
case "percent":
return 1
case "percentunit":
return 100

View File

@@ -13,5 +13,4 @@ func TestPercentConverter(t *testing.T) {
assert.Equal(t, Value{F: 100, U: "percent"}, percentConverter.Convert(Value{F: 1, U: "percentunit"}, "percent"))
assert.Equal(t, Value{F: 1, U: "percentunit"}, percentConverter.Convert(Value{F: 100, U: "percent"}, "percentunit"))
assert.Equal(t, Value{F: 0.01, U: "percentunit"}, percentConverter.Convert(Value{F: 1, U: "percent"}, "percentunit"))
assert.Equal(t, Value{F: 1, U: "percent"}, percentConverter.Convert(Value{F: 1, U: "%"}, "percent"))
}

View File

@@ -41,7 +41,7 @@ func FromTimeUnit(u Unit) Duration {
return Decisecond
case "s":
return Second
case "m", "min":
case "m":
return Minute
case "h":
return Hour

View File

@@ -24,8 +24,6 @@ func TestDurationConvert(t *testing.T) {
assert.Equal(t, Value{F: 60, U: "s"}, timeConverter.Convert(Value{F: 1, U: "m"}, "s"))
// 60 m = 1 h
assert.Equal(t, Value{F: 1, U: "h"}, timeConverter.Convert(Value{F: 60, U: "m"}, "h"))
// 60 min = 1 h
assert.Equal(t, Value{F: 1, U: "h"}, timeConverter.Convert(Value{F: 60, U: "min"}, "h"))
// 168 h = 1 w
assert.Equal(t, Value{F: 1, U: "w"}, timeConverter.Convert(Value{F: 168, U: "h"}, "w"))
// 1 h = 60 m

View File

@@ -20,7 +20,7 @@ func (*dataFormatter) Name() string {
func (f *dataFormatter) Format(value float64, unit string) string {
switch unit {
case "bytes", "By":
case "bytes":
return humanize.IBytes(uint64(value))
case "decbytes":
return humanize.Bytes(uint64(value))
@@ -28,23 +28,23 @@ func (f *dataFormatter) Format(value float64, unit string) string {
return humanize.IBytes(uint64(value * converter.Bit))
case "decbits":
return humanize.Bytes(uint64(value * converter.Bit))
case "kbytes", "kBy":
case "kbytes":
return humanize.IBytes(uint64(value * converter.Kibibit))
case "decKbytes", "deckbytes":
return humanize.IBytes(uint64(value * converter.Kilobit))
case "mbytes", "MBy":
case "mbytes":
return humanize.IBytes(uint64(value * converter.Mebibit))
case "decMbytes", "decmbytes":
return humanize.Bytes(uint64(value * converter.Megabit))
case "gbytes", "GBy":
case "gbytes":
return humanize.IBytes(uint64(value * converter.Gibibit))
case "decGbytes", "decgbytes":
return humanize.Bytes(uint64(value * converter.Gigabit))
case "tbytes", "TBy":
case "tbytes":
return humanize.IBytes(uint64(value * converter.Tebibit))
case "decTbytes", "dectbytes":
return humanize.Bytes(uint64(value * converter.Terabit))
case "pbytes", "PBy":
case "pbytes":
return humanize.IBytes(uint64(value * converter.Pebibit))
case "decPbytes", "decpbytes":
return humanize.Bytes(uint64(value * converter.Petabit))

View File

@@ -22,51 +22,51 @@ func (f *dataRateFormatter) Format(value float64, unit string) string {
switch unit {
case "binBps":
return humanize.IBytes(uint64(value)) + "/s"
case "Bps", "By/s":
case "Bps":
return humanize.Bytes(uint64(value)) + "/s"
case "binbps":
return humanize.IBytes(uint64(value*converter.BitPerSecond)) + "/s"
case "bps", "bit/s":
case "bps":
return humanize.Bytes(uint64(value*converter.BitPerSecond)) + "/s"
case "KiBs":
return humanize.IBytes(uint64(value*converter.KibibitPerSecond)) + "/s"
case "Kibits":
return humanize.IBytes(uint64(value*converter.KibibytePerSecond)) + "/s"
case "KBs", "kBy/s":
case "KBs":
return humanize.IBytes(uint64(value*converter.KilobitPerSecond)) + "/s"
case "Kbits", "kbit/s":
case "Kbits":
return humanize.IBytes(uint64(value*converter.KilobytePerSecond)) + "/s"
case "MiBs":
return humanize.IBytes(uint64(value*converter.MebibitPerSecond)) + "/s"
case "Mibits":
return humanize.IBytes(uint64(value*converter.MebibytePerSecond)) + "/s"
case "MBs", "MBy/s":
case "MBs":
return humanize.IBytes(uint64(value*converter.MegabitPerSecond)) + "/s"
case "Mbits", "Mbit/s":
case "Mbits":
return humanize.IBytes(uint64(value*converter.MegabytePerSecond)) + "/s"
case "GiBs":
return humanize.IBytes(uint64(value*converter.GibibitPerSecond)) + "/s"
case "Gibits":
return humanize.IBytes(uint64(value*converter.GibibytePerSecond)) + "/s"
case "GBs", "GBy/s":
case "GBs":
return humanize.IBytes(uint64(value*converter.GigabitPerSecond)) + "/s"
case "Gbits", "Gbit/s":
case "Gbits":
return humanize.IBytes(uint64(value*converter.GigabytePerSecond)) + "/s"
case "TiBs":
return humanize.IBytes(uint64(value*converter.TebibitPerSecond)) + "/s"
case "Tibits":
return humanize.IBytes(uint64(value*converter.TebibytePerSecond)) + "/s"
case "TBs", "TBy/s":
case "TBs":
return humanize.IBytes(uint64(value*converter.TerabitPerSecond)) + "/s"
case "Tbits", "Tbit/s":
case "Tbits":
return humanize.IBytes(uint64(value*converter.TerabytePerSecond)) + "/s"
case "PiBs":
return humanize.IBytes(uint64(value*converter.PebibitPerSecond)) + "/s"
case "Pibits":
return humanize.IBytes(uint64(value*converter.PebibytePerSecond)) + "/s"
case "PBs", "PBy/s":
case "PBs":
return humanize.IBytes(uint64(value*converter.PetabitPerSecond)) + "/s"
case "Pbits", "Pbit/s":
case "Pbits":
return humanize.IBytes(uint64(value*converter.PetabytePerSecond)) + "/s"
}
// When unit is not matched, return the value as it is.

View File

@@ -10,25 +10,14 @@ func TestData(t *testing.T) {
dataFormatter := NewDataFormatter()
assert.Equal(t, "1 B", dataFormatter.Format(1, "bytes"))
assert.Equal(t, "1 B", dataFormatter.Format(1, "By"))
assert.Equal(t, "1.0 KiB", dataFormatter.Format(1024, "bytes"))
assert.Equal(t, "1.0 KiB", dataFormatter.Format(1024, "By"))
assert.Equal(t, "2.3 GiB", dataFormatter.Format(2.3*1024, "mbytes"))
assert.Equal(t, "2.3 GiB", dataFormatter.Format(2.3*1024, "MBy"))
assert.Equal(t, "1.0 MiB", dataFormatter.Format(1024*1024, "bytes"))
assert.Equal(t, "1.0 MiB", dataFormatter.Format(1024*1024, "By"))
assert.Equal(t, "69 TiB", dataFormatter.Format(69*1024*1024, "mbytes"))
assert.Equal(t, "69 TiB", dataFormatter.Format(69*1024*1024, "MBy"))
assert.Equal(t, "102 KiB", dataFormatter.Format(102*1024, "bytes"))
assert.Equal(t, "102 KiB", dataFormatter.Format(102*1024, "By"))
assert.Equal(t, "240 MiB", dataFormatter.Format(240*1024, "kbytes"))
assert.Equal(t, "240 MiB", dataFormatter.Format(240*1024, "kBy"))
assert.Equal(t, "1.0 GiB", dataFormatter.Format(1024*1024, "kbytes"))
assert.Equal(t, "1.0 GiB", dataFormatter.Format(1024*1024, "kBy"))
assert.Equal(t, "23 GiB", dataFormatter.Format(23*1024*1024, "kbytes"))
assert.Equal(t, "23 GiB", dataFormatter.Format(23*1024*1024, "kBy"))
assert.Equal(t, "32 TiB", dataFormatter.Format(32*1024*1024*1024, "kbytes"))
assert.Equal(t, "32 TiB", dataFormatter.Format(32*1024*1024*1024, "kBy"))
assert.Equal(t, "24 MiB", dataFormatter.Format(24, "mbytes"))
assert.Equal(t, "24 MiB", dataFormatter.Format(24, "MBy"))
}

View File

@@ -18,17 +18,17 @@ var (
func FromUnit(u string) Formatter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min":
case "ns", "us", "µs", "ms", "s", "m", "h", "d":
return DurationFormatter
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy":
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes":
return DataFormatter
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s":
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits":
return DataRateFormatter
case "percent", "percentunit", "%":
case "percent", "percentunit":
return PercentFormatter
case "bool", "bool_yes_no", "bool_true_false", "bool_1_0":
return BoolFormatter
case "cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "{count}/s", "{ops}/s", "{req}/s", "{read}/s", "{write}/s", "{iops}/s", "{count}/min", "{ops}/min", "{read}/min", "{write}/min":
case "cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm":
return ThroughputFormatter
default:
return NoneFormatter

View File

@@ -22,7 +22,7 @@ func toPercentUnit(value float64, decimals DecimalCount) string {
func (f *percentFormatter) Format(value float64, unit string) string {
switch unit {
case "percent", "%":
case "percent":
return toPercent(value, nil)
case "percentunit":
return toPercentUnit(value, nil)

View File

@@ -22,25 +22,25 @@ func simpleCountUnit(value float64, decimals *int, symbol string) string {
func (f *throughputFormatter) Format(value float64, unit string) string {
switch unit {
case "cps", "{count}/s":
case "cps":
return simpleCountUnit(value, nil, "c/s")
case "ops", "{ops}/s":
case "ops":
return simpleCountUnit(value, nil, "op/s")
case "reqps", "{req}/s":
case "reqps":
return simpleCountUnit(value, nil, "req/s")
case "rps", "{read}/s":
case "rps":
return simpleCountUnit(value, nil, "r/s")
case "wps", "{write}/s":
case "wps":
return simpleCountUnit(value, nil, "w/s")
case "iops", "{iops}/s":
case "iops":
return simpleCountUnit(value, nil, "iops")
case "cpm", "{count}/min":
case "cpm":
return simpleCountUnit(value, nil, "c/m")
case "opm", "{ops}/min":
case "opm":
return simpleCountUnit(value, nil, "op/m")
case "rpm", "{read}/min":
case "rpm":
return simpleCountUnit(value, nil, "r/m")
case "wpm", "{write}/min":
case "wpm":
return simpleCountUnit(value, nil, "w/m")
}
// When unit is not matched, return the value as it is.

View File

@@ -10,11 +10,6 @@ func TestThroughput(t *testing.T) {
throughputFormatter := NewThroughputFormatter()
assert.Equal(t, "10 req/s", throughputFormatter.Format(10, "reqps"))
assert.Equal(t, "10 req/s", throughputFormatter.Format(10, "{req}/s"))
assert.Equal(t, "1K req/s", throughputFormatter.Format(1000, "reqps"))
assert.Equal(t, "1K req/s", throughputFormatter.Format(1000, "{req}/s"))
assert.Equal(t, "1M req/s", throughputFormatter.Format(1000000, "reqps"))
assert.Equal(t, "1M req/s", throughputFormatter.Format(1000000, "{req}/s"))
assert.Equal(t, "10 c/m", throughputFormatter.Format(10, "cpm"))
assert.Equal(t, "10 c/m", throughputFormatter.Format(10, "{count}/min"))
}

View File

@@ -26,7 +26,7 @@ func (f *durationFormatter) Format(value float64, unit string) string {
return toMilliSeconds(value)
case "s":
return toSeconds(value)
case "m", "min":
case "m":
return toMinutes(value)
case "h":
return toHours(value)

View File

@@ -26,5 +26,4 @@ func TestDuration(t *testing.T) {
assert.Equal(t, "1.82 min", durationFormatter.Format(109200000000, "ns"))
assert.Equal(t, "1.27 day", durationFormatter.Format(109800000000000, "ns"))
assert.Equal(t, "2 day", durationFormatter.Format(172800000, "ms"))
assert.Equal(t, "1 hour", durationFormatter.Format(60, "min"))
}

View File

@@ -1334,35 +1334,6 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
matchType: "1", // Once
target: 200, // 200 GB
},
{
targetUnit: "decgbytes",
yAxisUnit: "By",
values: [][]interface{}{
{float64(2863284053), "attr", time.Now()}, // 2.86 GB
{float64(2863388842), "attr", time.Now().Add(1 * time.Second)}, // 2.86 GB
{float64(300947400), "attr", time.Now().Add(2 * time.Second)}, // 0.3 GB
{float64(299316000), "attr", time.Now().Add(3 * time.Second)}, // 0.3 GB
{float64(66640400.00000001), "attr", time.Now().Add(4 * time.Second)}, // 66.64 MB
},
expectAlerts: 0,
compareOp: "1", // Above
matchType: "1", // Once
target: 200, // 200 GB
},
{
targetUnit: "h",
yAxisUnit: "min",
values: [][]interface{}{
{float64(55), "attr", time.Now()}, // 55 minutes
{float64(57), "attr", time.Now().Add(1 * time.Minute)}, // 57 minutes
{float64(30), "attr", time.Now().Add(2 * time.Minute)}, // 30 minutes
{float64(29), "attr", time.Now().Add(3 * time.Minute)}, // 29 minutes
},
expectAlerts: 0,
compareOp: "1", // Above
matchType: "1", // Once
target: 1, // 1 hour
},
}
logger := instrumentationtest.New().Logger()

View File

@@ -86,26 +86,5 @@ var (
matchType: "1", // Once
target: 200, // 200 GB
},
{
targetUnit: "decgbytes",
yAxisUnit: "By",
values: [][]interface{}{
{float64(2863284053), "attr", time.Now()}, // 2.86 GB
{float64(2863388842), "attr", time.Now().Add(1 * time.Second)}, // 2.86 GB
{float64(300947400), "attr", time.Now().Add(2 * time.Second)}, // 0.3 GB
{float64(299316000), "attr", time.Now().Add(3 * time.Second)}, // 0.3 GB
{float64(66640400.00000001), "attr", time.Now().Add(4 * time.Second)}, // 66.64 MB
},
metaValues: [][]interface{}{},
createTableValues: [][]interface{}{
{"statement"},
},
attrMetaValues: [][]interface{}{},
resourceMetaValues: [][]interface{}{},
expectAlerts: 0,
compareOp: "1", // Above
matchType: "1", // Once
target: 200, // 200 GB
},
}
)