mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-11 12:04:27 +00:00
Compare commits
11 Commits
variables-
...
SIG-8704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df36c11f6 | ||
|
|
a33b13c166 | ||
|
|
700e021228 | ||
|
|
5a0b850e8c | ||
|
|
70d542e3fe | ||
|
|
cd525221e5 | ||
|
|
7128d78415 | ||
|
|
9edc1fd180 | ||
|
|
de36acf5f1 | ||
|
|
299c3c1135 | ||
|
|
46cf13cd2f |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -49,5 +49,4 @@ export enum QueryParams {
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
variableConfigs = 'variableConfigs',
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
.metrics-search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.metrics-search-options {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -14,5 +14,3 @@ export function variablePropsToPayloadVariables(
|
||||
|
||||
return payloadVariables;
|
||||
}
|
||||
|
||||
export const ALL_SELECT_VALUE = '__ALL__';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user