Compare commits

..

6 Commits

Author SHA1 Message Date
Srikanth Chekuri
a7ddd2ddf0 chore: do not send field context as tag for deprecated fields (#8902) 2025-08-24 11:14:12 +05:30
Srikanth Chekuri
4d72f47758 chore: parse into number alias for mat column from statement (#8900) 2025-08-24 09:44:58 +05:30
Vikrant Gupta
b5b513f1e0 chore(meter): add warnings and make meter live in sidenav (#8882)
* chore(meter): add warnings and make meter live in sidenav

* chore(meter): add warnings and make meter live in sidenav

* chore(meter): add warnings and make meter live in sidenav

* chore(meter): add warnings and make meter live in sidenav

* chore(meter): add warnings and make meter live in sidenav

* chore(meter): add warnings and make meter live in sidenav
2025-08-23 15:00:07 +05:30
Nityananda Gohain
4878f725ea fix: use lower and convert re2 to string in fulltext (#8887)
* fix: use lower and convert re2 to string in fulltext

* fix: minor error change

* fix: address comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-08-22 20:20:42 +05:30
Srikanth Chekuri
eca13075e9 fix: related links for rule history page (#8883) 2025-08-22 16:19:27 +05:30
Amlan Kumar Nandy
e5ab664483 fix: resolve sentry issues in alert list (#8878)
* fix: resolve sentry issues in alert list

* chore: update the key

---------

Co-authored-by: srikanthccv <srikanth.chekuri92@gmail.com>
2025-08-21 19:21:15 +05:30
46 changed files with 777 additions and 2425 deletions

View File

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

View File

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

View File

@@ -66,9 +66,46 @@ function getSignalType(dataSource: string): 'traces' | 'logs' | 'metrics' {
return 'metrics';
}
/**
* Creates base spec for builder queries
*/
function isDeprecatedField(fieldName: string): boolean {
const deprecatedIntrinsicFields = [
'traceID',
'spanID',
'parentSpanID',
'spanKind',
'durationNano',
'statusCode',
'statusMessage',
'statusCodeString',
];
const deprecatedCalculatedFields = [
'responseStatusCode',
'externalHttpUrl',
'httpUrl',
'externalHttpMethod',
'httpMethod',
'httpHost',
'dbName',
'dbOperation',
'hasError',
'isRemote',
'serviceName',
'httpRoute',
'msgSystem',
'msgOperation',
'dbSystem',
'rpcSystem',
'rpcService',
'rpcMethod',
'peerService',
];
return (
deprecatedIntrinsicFields.includes(fieldName) ||
deprecatedCalculatedFields.includes(fieldName)
);
}
function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
@@ -140,16 +177,29 @@ function createBaseSpec(
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(
(column: any): TelemetryFieldKey => ({
name: column.name ?? column.key,
fieldDataType:
column?.fieldDataType ?? (column?.dataType as FieldDataType),
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
signal: column?.signal ?? undefined,
}),
(column: any): TelemetryFieldKey => {
const fieldName = column.name ?? column.key;
const isDeprecated = isDeprecatedField(fieldName);
const fieldObj: TelemetryFieldKey = {
name: fieldName,
fieldDataType:
column?.fieldDataType ?? (column?.dataType as FieldDataType),
signal: column?.signal ?? undefined,
};
// Only add fieldContext if the field is NOT deprecated
if (!isDeprecated && fieldName !== 'name') {
fieldObj.fieldContext =
column?.fieldContext ?? (column?.type as FieldContext);
}
return fieldObj;
},
),
};
}
// Utility to parse aggregation expressions with optional alias
export function parseAggregations(
expression: string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export const flattenLabels = (labels: Labels): ILabelRecord[] => {
if (!hiddenLabels.includes(key)) {
recs.push({
key,
value: labels[key],
value: labels[key] || '',
});
}
});

View File

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

View File

@@ -272,12 +272,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
width: 80,
key: 'severity',
sorter: (a, b): number =>
(a.labels ? a.labels.severity.length : 0) -
(b.labels ? b.labels.severity.length : 0),
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
render: (value): JSX.Element => {
const objectKeys = Object.keys(value);
const objectKeys = value ? Object.keys(value) : [];
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
const severityValue = value[withSeverityKey];
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
return <Typography>{severityValue}</Typography>;
},
@@ -290,7 +289,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
align: 'center',
width: 100,
render: (value): JSX.Element => {
const objectKeys = Object.keys(value);
const objectKeys = value ? Object.keys(value) : [];
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
if (withOutSeverityKeys.length === 0) {

View File

@@ -16,6 +16,12 @@
padding: 20px;
gap: 36px;
.info {
display: flex;
flex-direction: column;
gap: 12px;
}
.meter-column-graph {
.row-card {
background-color: var(--bg-ink-400);

View File

@@ -1,6 +1,6 @@
import './BreakDown.styles.scss';
import { Typography } from 'antd';
import { Alert, Typography } from 'antd';
// import useFilterConfig from 'components/QuickFilters/hooks/useFilterConfig';
// import { SignalType } from 'components/QuickFilters/types';
import { QueryParams } from 'constants/query';
@@ -171,6 +171,19 @@ function BreakDown(): JSX.Element {
<DateTimeSelectionV2 showAutoRefresh={false} />
</section>
<section className="meter-explorer-graphs">
<section className="info">
<Alert
type="info"
showIcon
message="Billing is calculated in UTC. To match your meter data with billing, select full-day ranges in UTC time (00:00 23:59 UTC).
For example, if youre in IST, for the billing of Jan 1, select your time range as Jan 1, 5:30 AM Jan 2, 5:29 AM IST."
/>
<Alert
type="warning"
showIcon
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
/>
</section>
<section className="total">
<Section
id={sections[0].id}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ 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,
@@ -105,7 +104,7 @@ 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) {
@@ -122,7 +121,6 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
@@ -130,7 +128,6 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
@@ -173,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}
@@ -201,9 +198,9 @@ function DashboardVariableSelection(): JSX.Element | null {
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
),
)}
</Row>
))}
</Row>
</>
);
}

View File

@@ -1,385 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import './DashboardVariableSelection.styles.scss';
import { Tooltip, Typography } from 'antd';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty, isUndefined } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual } from './util';
import { getSelectValue } from './VariableItem';
interface DynamicVariableSelectionProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}
function DynamicVariableSelection({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableSelectionProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) return 'no_variables';
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
],
{
enabled: variableData.type === 'DYNAMIC',
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
),
onSuccess: (data) => {
setOptionsData(data.payload?.normalizedValues || []);
setIsComplete(data.payload?.complete || false);
setFilteredOptionsData(data.payload?.normalizedValues || []);
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
}
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(
variableData.name,
variableData.id,
value,
optionsData.every((v) => value.includes(v.toString())),
Array.isArray(value) &&
!value.every((v) => optionsData.includes(v.toString())),
);
}
}
},
[variableData, onValueUpdate, optionsData],
);
useEffect(() => {
if (
variableData.dynamicVariablesSource &&
variableData.dynamicVariablesAttribute
) {
refetch();
}
}, [
refetch,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
return;
}
const localFilteredOptionsData: (string | number | boolean)[] = [];
optionsData.forEach((option) => {
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
localFilteredOptionsData.push(option);
}
});
setFilteredOptionsData(localFilteredOptionsData);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData],
);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
const allOptions = [
...new Set([
...optionsData.map((option) => option.toString()),
...(variableData.selectedValue
? Array.isArray(variableData.selectedValue)
? variableData.selectedValue.map((v) => v.toString())
: [variableData.selectedValue.toString()]
: []),
]),
];
setTempSelection(allOptions);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
setTempSelection(undefined);
}
};
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
selectedValue,
tempSelection,
optionsData,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
</Typography.Text>
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={
(tempSelection || selectValue) === ALL_SELECT_VALUE
? 'ALL'
: tempSelection || selectValue
}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -8,14 +8,23 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, Popover, Tooltip, Typography } from 'antd';
import {
Checkbox,
Input,
Popover,
Select,
Tag,
Tooltip,
Typography,
} from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { debounce, isArray, isString } from 'lodash-es';
import map from 'lodash-es/map';
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -24,10 +33,17 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
const ALL_SELECT_VALUE = '__ALL__';
enum ToggleTagValue {
Only = 'Only',
All = 'All',
}
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
@@ -42,7 +58,7 @@ interface VariableItemProps {
dependencyData: IDependencyData | null;
}
export const getSelectValue = (
const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
@@ -67,9 +83,6 @@ function VariableItem({
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -133,21 +146,18 @@ function VariableItem({
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
const value = variableData.selectedValue;
let value = variableData.selectedValue;
let allSelected = false;
// The default value for multi-select is ALL and first value for
// single select
// console.log(valueNotInList);
// if (valueNotInList) {
// if (variableData.multiSelect) {
// value = newOptionsData;
// allSelected = true;
// } else {
// [value] = newOptionsData;
// }
// } else
if (variableData.multiSelect) {
if (valueNotInList) {
if (variableData.multiSelect) {
value = newOptionsData;
allSelected = true;
} else {
[value] = newOptionsData;
}
} else if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
@@ -232,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);
}
}
};
@@ -302,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
@@ -363,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>
@@ -390,73 +428,105 @@ function VariableItem({
}}
/>
) : (
optionsData &&
(variableData.multiSelect ? (
<CustomMultiSelect
!errorMessage &&
optionsData && (
<Select
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleTempChange}
defaultValue={selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
mode={mode}
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
maxTagCount={4}
getPopupContainer={popupContainer}
value={tempSelection || selectValue}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
tagRender={(props): JSX.Element => (
<Tag closable onClose={props.onClose}>
{props.value}
</Tag>
)}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
errorMessage={errorMessage}
/>
))
>
{enableSelectAll && (
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
<Checkbox checked={variableData.allSelected} />
ALL
</div>
</Select.Option>
)}
{map(optionsData, (option) => (
<Select.Option
data-testid={`option-${option}`}
key={option.toString()}
value={option}
>
<div
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
>
{variableData.multiSelect && (
<Checkbox
onChange={(e): void => {
e.stopPropagation();
e.preventDefault();
handleOptionSelect(e, option);
}}
checked={
variableData.allSelected ||
option.toString() === selectValue ||
(Array.isArray(selectValue) &&
selectValue?.includes(option.toString()))
}
/>
)}
<div
className="dropdown-value"
{...retProps(option as string)}
onClick={(e): void => handleToggle(e as any, option as string)}
>
<Typography.Text
ellipsis={{
tooltip: {
placement: variableData.multiSelect ? 'top' : 'right',
autoAdjustOverflow: true,
},
}}
className="option-text"
>
{option.toString()}
</Typography.Text>
{variableData.multiSelect &&
optionState.tag === option.toString() &&
optionState.visible &&
ensureValidOption(option as string) && (
<Typography.Text className="toggle-tag-label">
{currentToggleTagValue({ option: option as string })}
</Typography.Text>
)}
</div>
</div>
</Select.Option>
))}
</Select>
)
)}
{variableData.type !== 'TEXTBOX' && errorMessage && (
<span style={{ margin: '0 0.5rem' }}>

View File

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

View File

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

View File

@@ -270,7 +270,7 @@ export const defaultMoreMenuItems: SidebarItem[] = [
label: 'Cost Meter',
icon: <ChartArea size={16} />,
isNew: false,
isEnabled: false,
isEnabled: true,
isBeta: true,
itemKey: 'meter-explorer',
},

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ export type AlertHeaderProps = {
state: string;
alert: string;
id: string;
labels: Record<string, string>;
labels: Record<string, string | undefined> | undefined;
disabled: boolean;
};
};
@@ -23,13 +23,14 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { alertRuleState } = useAlertRule();
const [updatedName, setUpdatedName] = useState(alertName);
const labelsWithoutSeverity = useMemo(
() =>
Object.fromEntries(
const labelsWithoutSeverity = useMemo(() => {
if (labels) {
return Object.fromEntries(
Object.entries(labels).filter(([key]) => key !== 'severity'),
),
[labels],
);
);
}
return {};
}, [labels]);
return (
<div className="alert-info">
@@ -43,7 +44,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
</div>
</div>
<div className="bottom-section">
{labels.severity && <AlertSeverity severity={labels.severity} />}
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus

View File

@@ -48,7 +48,7 @@ export interface RuleCondition {
seasonality?: string;
}
export interface Labels {
[key: string]: string;
[key: string]: string | undefined;
}
export interface AlertRuleStats {

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
@@ -1035,9 +1036,54 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
// to get the correct query range
start := end.Add(-time.Duration(rule.EvalWindow)).Add(-3 * time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogs(start, end, newFilters)
if rule.Version != "v5" {
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogs(start, end, newFilters)
} else {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
}
} else if rule.AlertType == ruletypes.AlertTypeTraces {
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTraces(start, end, newFilters)
if rule.Version != "v5" {
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTraces(start, end, newFilters)
} else {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
}
}
}
}

View File

@@ -478,6 +478,11 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
return resultVector, nil
}
if queryResult == nil {
r.logger.WarnContext(ctx, "query result is nil", "rule_name", r.Name(), "query_name", selectedQuery)
return resultVector, nil
}
for _, series := range queryResult.Series {
smpl, shouldAlert := r.ShouldAlert(*series)
if shouldAlert {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"reflect"
"regexp"
"strconv"
"strings"
@@ -185,3 +186,11 @@ func FormatValueForContains(value any) string {
}
}
}
func FormatFullTextSearch(input string) string {
if _, err := regexp.Compile(input); err != nil {
// Not a valid regex -> treat as literal substring
return regexp.QuoteMeta(input)
}
return input
}

View File

@@ -273,3 +273,30 @@ func TestFormatValueForContains_LargeNumberScientificNotation(t *testing.T) {
assert.Equal(t, "521509198310", result)
assert.NotEqual(t, "5.2150919831e+11", result)
}
func TestFormatFullTextSearch(t *testing.T) {
tests := []struct {
input string
expected string
}{
// valid regex, unchanged
{"foo.*bar", "foo.*bar"},
// invalid regex, escaped
{"[ERROR-1234]", `\[ERROR-1234\]`},
// literal with +
{"C++ Error", `C\+\+ Error`},
// IP address, valid regex but unsafe chars
{"10.0.0.1", "10.0.0.1"},
// java class, '.' will still be regex wildcard
{"java.lang.NullPointerException", "java.lang.NullPointerException"},
// a-o invalid character class range
{"[LocalLog partition=__cluster_metadata-0,", "\\[LocalLog partition=__cluster_metadata-0,"},
{"[abcd]", "[abcd]"},
}
for _, tt := range tests {
got := FormatFullTextSearch(tt.input)
if got != tt.expected {
t.Errorf("FormatFullTextSearch(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}

View File

@@ -298,8 +298,9 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
child := ctx.GetChild(0)
if keyCtx, ok := child.(*grammar.KeyContext); ok {
// create a full text search condition on the body field
keyText := keyCtx.GetText()
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, keyText, v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -319,7 +320,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
return ""
}
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, text, v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -594,7 +595,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, "full text search is not supported")
return ""
}
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, text, v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""

View File

@@ -64,6 +64,10 @@ func (c *conditionBuilder) conditionFor(
return sb.ILike(tblFieldName, value), nil
case qbtypes.FilterOperatorNotLike:
return sb.NotILike(tblFieldName, value), nil
case qbtypes.FilterOperatorRegexp:
return fmt.Sprintf(`match(LOWER(%s), LOWER(%s))`, tblFieldName, sb.Var(value)), nil
case qbtypes.FilterOperatorNotRegexp:
return fmt.Sprintf(`NOT match(LOWER(%s), LOWER(%s))`, tblFieldName, sb.Var(value)), nil
}
}

View File

@@ -60,7 +60,6 @@ func NewFieldMapper() qbtypes.FieldMapper {
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
return logsV2Columns["resources_string"], nil

View File

@@ -41,17 +41,25 @@ func TestFilterExprLogs(t *testing.T) {
// Single word searches
{
category: "Single word",
query: "download",
query: "Download",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedArgs: []any{"download"},
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"Download"},
expectedErrorContains: "",
},
{
category: "Single word invalid regex",
query: "'[LocalLog partition=__cluster_metadata-0,'",
shouldPass: true,
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"\\[LocalLog partition=__cluster_metadata-0,"},
expectedErrorContains: "",
},
{
category: "Single word",
query: "LAMBDA",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"LAMBDA"},
expectedErrorContains: "",
},
@@ -59,7 +67,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word",
query: "AccessDenied",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"AccessDenied"},
expectedErrorContains: "",
},
@@ -67,7 +75,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word",
query: "42069",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"42069"},
expectedErrorContains: "",
},
@@ -75,7 +83,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word",
query: "pulljob",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"pulljob"},
expectedErrorContains: "",
},
@@ -91,7 +99,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word with spaces",
query: `" 504 "`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{" 504 "},
expectedErrorContains: "",
},
@@ -99,7 +107,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word with spaces",
query: `"Importing "`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"Importing "},
expectedErrorContains: "",
},
@@ -107,7 +115,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word with spaces",
query: `"Job ID"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"Job ID"},
expectedErrorContains: "",
},
@@ -123,7 +131,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "srikanth@signoz.io",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"srikanth@signoz.io"},
expectedErrorContains: "",
},
@@ -131,7 +139,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "cancel_membership",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"cancel_membership"},
expectedErrorContains: "",
},
@@ -139,7 +147,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: `"ERROR: cannot execute update() in a read-only context"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"ERROR: cannot execute update() in a read-only context"},
expectedErrorContains: "",
},
@@ -153,7 +161,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "https://example.com/user/default/0196877a-f01f-785e-a937-5da0a3efbb75",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"https://example.com/user/default/0196877a-f01f-785e-a937-5da0a3efbb75"},
expectedErrorContains: "",
},
@@ -161,7 +169,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "\"STEPS_PER_DAY\"",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"STEPS_PER_DAY"},
expectedErrorContains: "",
},
@@ -169,7 +177,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "#bvn",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"#bvn"},
expectedErrorContains: "",
},
@@ -177,7 +185,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "question?mark",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"question?mark"},
expectedErrorContains: "",
},
@@ -185,7 +193,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "backslash\\\\escape",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"backslash\\\\escape"},
expectedErrorContains: "",
},
@@ -193,7 +201,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "underscore_separator",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"underscore_separator"},
expectedErrorContains: "",
},
@@ -201,7 +209,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "\"Text with [brackets]\"",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"Text with [brackets]"},
expectedErrorContains: "",
},
@@ -211,7 +219,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "Fail to parse",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"Fail", "to", "parse"},
expectedErrorContains: "",
},
@@ -219,7 +227,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "Importing file",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"Importing", "file"},
expectedErrorContains: "",
},
@@ -227,7 +235,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "sync account status",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"sync", "account", "status"},
expectedErrorContains: "",
},
@@ -235,7 +243,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "Download CSV Reports",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"Download", "CSV", "Reports"},
expectedErrorContains: "",
},
@@ -243,7 +251,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "Emitted event to the Kafka topic",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?) AND match(body, ?) AND match(body, ?) AND match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"Emitted", "event", "to", "the", "Kafka", "topic"},
expectedErrorContains: "",
},
@@ -251,7 +259,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Multi word",
query: "\"user authentication\" failed",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"user authentication", "failed"},
expectedErrorContains: "",
},
@@ -261,7 +269,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "ID search",
query: "250430165501118HIgesxlEb9",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"250430165501118HIgesxlEb9"},
expectedErrorContains: "",
},
@@ -269,7 +277,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "ID search",
query: "d7b9d77aefa95aef19719775c10fda60c28342f23657d1e27304d6c59a3c3004",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"d7b9d77aefa95aef19719775c10fda60c28342f23657d1e27304d6c59a3c3004"},
expectedErrorContains: "",
},
@@ -277,7 +285,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "ID search",
query: "51183870",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"51183870"},
expectedErrorContains: "",
},
@@ -285,7 +293,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "ID search",
query: "79f82635-d014-4f99-adf5-41d31d291ae3",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"79f82635-d014-4f99-adf5-41d31d291ae3"},
expectedErrorContains: "",
},
@@ -295,7 +303,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unicode",
query: "café",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"café"},
expectedErrorContains: "",
},
@@ -303,7 +311,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unicode",
query: "résumé",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"résumé"},
expectedErrorContains: "",
},
@@ -311,7 +319,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unicode",
query: "Россия",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"Россия"},
expectedErrorContains: "",
},
@@ -319,7 +327,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unicode",
query: "\"I do not like emojis ❤️\"",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"I do not like emojis ❤️"},
expectedErrorContains: "",
},
@@ -329,7 +337,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Number format",
query: "123",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"123"},
expectedErrorContains: "",
},
@@ -337,7 +345,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Number format",
query: "3.14159",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"3.14159"},
expectedErrorContains: "",
},
@@ -345,7 +353,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Number format",
query: "-42",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"-42"},
expectedErrorContains: "",
},
@@ -353,7 +361,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Number format",
query: "1e6",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"1e6"},
expectedErrorContains: "",
},
@@ -361,15 +369,15 @@ func TestFilterExprLogs(t *testing.T) {
category: "Number format",
query: "+100",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedArgs: []any{"+100"},
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"\\+100"},
expectedErrorContains: "",
},
{
category: "Number format",
query: "0xFF",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"0xFF"},
expectedErrorContains: "",
},
@@ -379,7 +387,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "critical NOT resolved status=open",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND NOT (match(body, ?)) AND (toString(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND NOT (match(LOWER(body), LOWER(?))) AND (toString(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))",
expectedArgs: []any{"critical", "resolved", "open", true},
expectedErrorContains: "",
},
@@ -387,7 +395,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "database error type=mysql",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?) AND (attributes_string['type'] = ? AND mapContains(attributes_string, 'type') = ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)) AND (attributes_string['type'] = ? AND mapContains(attributes_string, 'type') = ?))",
expectedArgs: []any{"database", "error", "mysql", true},
expectedErrorContains: "",
},
@@ -395,7 +403,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "\"connection timeout\" duration>30",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?))",
expectedArgs: []any{"connection timeout", float64(30), true},
expectedErrorContains: "",
},
@@ -403,7 +411,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "warning level=critical",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (attributes_string['level'] = ? AND mapContains(attributes_string, 'level') = ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (attributes_string['level'] = ? AND mapContains(attributes_string, 'level') = ?))",
expectedArgs: []any{"warning", "critical", true},
expectedErrorContains: "",
},
@@ -411,7 +419,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "error service.name=authentication",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (resources_string['service.name'] = ? AND mapContains(resources_string, 'service.name') = ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (resources_string['service.name'] = ? AND mapContains(resources_string, 'service.name') = ?))",
expectedArgs: []any{"error", "authentication", true},
expectedErrorContains: "",
},
@@ -421,7 +429,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with parentheses",
query: "error (status.code=500 OR status.code=503)",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))))",
expectedArgs: []any{"error", float64(500), true, float64(503), true},
expectedErrorContains: "",
},
@@ -429,7 +437,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with parentheses",
query: "(status.code=500 OR status.code=503) error",
shouldPass: true,
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))) AND match(body, ?))",
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), true, float64(503), true, "error"},
expectedErrorContains: "",
},
@@ -437,7 +445,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with parentheses",
query: "error AND (status.code=500 OR status.code=503)",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))))",
expectedArgs: []any{"error", float64(500), true, float64(503), true},
expectedErrorContains: "",
},
@@ -445,7 +453,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with parentheses",
query: "(status.code=500 OR status.code=503) AND error",
shouldPass: true,
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))) AND match(body, ?))",
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code') = ?))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), true, float64(503), true, "error"},
expectedErrorContains: "",
},
@@ -455,7 +463,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Whitespace with FREETEXT",
query: "term1 term2",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"term1", "term2"},
expectedErrorContains: "",
},
@@ -465,7 +473,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key token conflict",
query: "status.code",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"status.code"},
expectedErrorContains: "",
},
@@ -473,7 +481,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key token conflict",
query: "array_field",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"array_field"},
expectedErrorContains: "",
},
@@ -481,7 +489,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key token conflict",
query: "user_id.value",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"user_id.value"},
expectedErrorContains: "",
}, // Could be a key with dot notation or FREETEXT
@@ -491,7 +499,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "true",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"true"},
expectedErrorContains: "",
}, // Could be interpreted as boolean or FREETEXT
@@ -499,7 +507,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "false",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"false"},
expectedErrorContains: "",
}, // Could be interpreted as boolean or FREETEXT
@@ -507,7 +515,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "null",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"null"},
expectedErrorContains: "",
}, // Special value or FREETEXT
@@ -515,7 +523,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "123abc",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"123abc"},
expectedErrorContains: "",
}, // Starts with number but contains letters
@@ -523,7 +531,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "0x123F",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"0x123F"},
expectedErrorContains: "",
}, // Hex number format
@@ -531,7 +539,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "1.2.3",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"1.2.3"},
expectedErrorContains: "",
}, // Version number format
@@ -539,7 +547,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "a+b-c*d/e",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"a+b-c*d/e"},
expectedErrorContains: "",
}, // Mathematical expression as FREETEXT
@@ -547,7 +555,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Random cases",
query: "http://example.com/path",
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"http://example.com/path"},
expectedErrorContains: "",
}, // URL as FREETEXT
@@ -655,7 +663,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: `"not!equal"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"not!equal"},
expectedErrorContains: "",
},
@@ -671,7 +679,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: `"greater>than"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"greater>than"},
expectedErrorContains: "",
},
@@ -687,7 +695,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: `"less<than"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"less<than"},
expectedErrorContains: "",
},
@@ -695,7 +703,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "single'quote'",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"single", "quote"},
expectedErrorContains: "",
},
@@ -703,7 +711,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "quoted\"text\"",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND match(body, ?))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{"quoted", "text"},
expectedErrorContains: "",
},
@@ -719,7 +727,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "function(param)",
shouldPass: true,
expectedQuery: "WHERE (match(body, ?) AND (match(body, ?)))",
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (match(LOWER(body), LOWER(?))))",
expectedArgs: []any{"function", "param"},
expectedErrorContains: "",
},
@@ -727,7 +735,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: `"function(param)"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"function(param)"},
expectedErrorContains: "",
},
@@ -743,7 +751,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: `"user=admin"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{"user=admin"},
expectedErrorContains: "",
},
@@ -1357,7 +1365,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "REGEXP operator",
query: `"^\[(INFO|WARN|ERROR|DEBUG)\] .+$"`,
shouldPass: true,
expectedQuery: "WHERE match(body, ?)",
expectedQuery: "WHERE match(LOWER(body), LOWER(?))",
expectedArgs: []any{`^\[(INFO|WARN|ERROR|DEBUG)\] .+$`},
expectedErrorContains: "",
},

View File

@@ -266,7 +266,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(body, ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -294,7 +294,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (match(body, ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (match(LOWER(body), LOWER(?))) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,

View File

@@ -63,11 +63,11 @@ func (v *TelemetryFieldVisitor) VisitColumnDef(expr *parser.ColumnDef) error {
case "bool":
fieldDataType = telemetrytypes.FieldDataTypeBool
case "int", "int64":
fieldDataType = telemetrytypes.FieldDataTypeFloat64
fieldDataType = telemetrytypes.FieldDataTypeNumber
case "float", "float64":
fieldDataType = telemetrytypes.FieldDataTypeFloat64
fieldDataType = telemetrytypes.FieldDataTypeNumber
case "number":
fieldDataType = telemetrytypes.FieldDataTypeFloat64
fieldDataType = telemetrytypes.FieldDataTypeNumber
default:
return nil // Unknown data type
}

View File

@@ -121,7 +121,7 @@ func TestExtractFieldKeysFromTblStatement(t *testing.T) {
{
Name: "input_size",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
Materialized: true,
},
{

View File

@@ -73,7 +73,7 @@ func (m *alertMigrateV5) Migrate(ctx context.Context, ruleData map[string]any) b
panelType = pt
}
if m.updateQueryData(ctx, queryMap, "v4", panelType) {
if m.updateQueryData(ctx, queryMap, version, panelType) {
updated = true
}
m.logger.InfoContext(ctx, "migrated querymap")

View File

@@ -366,7 +366,7 @@ func (mc *migrateCommon) createAggregations(ctx context.Context, queryData map[s
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": aggregateOp,
"timeAggregation": queryData["timeAggregation"],
"spaceAggregation": queryData["spaceAggregation"],
}
if reduceTo, ok := queryData["reduceTo"].(string); ok {

View File

@@ -11,6 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// this file contains common structs and methods used by
@@ -131,10 +133,30 @@ func (rc *RuleCondition) GetSelectedQueryName() string {
for name := range rc.CompositeQuery.BuilderQueries {
queryNames[name] = struct{}{}
}
for _, query := range rc.CompositeQuery.Queries {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
queryNames[spec.Name] = struct{}{}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
queryNames[spec.Name] = struct{}{}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
queryNames[spec.Name] = struct{}{}
case qbtypes.QueryBuilderFormula:
queryNames[spec.Name] = struct{}{}
}
}
} else if rc.QueryType() == v3.QueryTypeClickHouseSQL {
for name := range rc.CompositeQuery.ClickHouseQueries {
queryNames[name] = struct{}{}
}
for _, query := range rc.CompositeQuery.Queries {
switch spec := query.Spec.(type) {
case qbtypes.ClickHouseQuery:
queryNames[spec.Name] = struct{}{}
}
}
}
}