Skip to content

Commit

Permalink
[ML] Transforms: Support wildcards in the alerting rule flyout (elast…
Browse files Browse the repository at this point in the history
…ic#204226)

## Summary


Closes elastic#166810

- Adds wildcards support for the tranform health alerting rule. 
- Populates transforms with alerting rules based on wildcard
expressions.
- Excludes `alerting_rules` from the JSON tab.  

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
  • Loading branch information
darnautov authored Dec 17, 2024
1 parent 9f53fbb commit fd98643
Show file tree
Hide file tree
Showing 6 changed files with 344 additions and 68 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import type { TransformSelectorControlProps } from './transform_selector_control';
import { TransformSelectorControl } from './transform_selector_control';

describe('TransformSelectorControl', () => {
const defaultProps: TransformSelectorControlProps = {
label: 'Select Transforms',
errors: [],
onChange: jest.fn(),
selectedOptions: [],
options: ['transform1', 'transform2'],
allowSelectAll: true,
};

it('renders without crashing', () => {
const { getByLabelText } = render(<TransformSelectorControl {...defaultProps} />);
expect(getByLabelText('Select Transforms')).toBeInTheDocument();
});

it('displays options correctly', () => {
const { getByText } = render(<TransformSelectorControl {...defaultProps} />);
fireEvent.click(getByText('Select Transforms'));
expect(getByText('transform1')).toBeInTheDocument();
expect(getByText('transform2')).toBeInTheDocument();
expect(getByText('*')).toBeInTheDocument();
});

it('calls onChange with selected options', () => {
const { getByText } = render(<TransformSelectorControl {...defaultProps} />);
fireEvent.click(getByText('Select Transforms'));
fireEvent.click(getByText('transform1'));
expect(defaultProps.onChange).toHaveBeenCalledWith(['transform1']);
});

it('only allows wildcards as custom options', () => {
const { getByText, getByTestId } = render(<TransformSelectorControl {...defaultProps} />);
fireEvent.click(getByText('Select Transforms'));
const input = getByTestId('comboBoxSearchInput');

fireEvent.change(input, { target: { value: 'custom' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(defaultProps.onChange).not.toHaveBeenCalledWith(['custom']);

fireEvent.change(input, { target: { value: 'custom*' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(defaultProps.onChange).toHaveBeenCalledWith(['custom*']);
});

it('displays errors correctly', () => {
const errorProps = { ...defaultProps, errors: ['Error message'] };
const { getByText } = render(<TransformSelectorControl {...errorProps} />);
expect(getByText('Error message')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
* 2.0.
*/

import type { EuiComboBoxProps } from '@elastic/eui';
import type { EuiComboBoxOptionsListProps, EuiComboBoxProps } from '@elastic/eui';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import type { FC } from 'react';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { isDefined } from '@kbn/ml-is-defined';
import { i18n } from '@kbn/i18n';
import { ALL_TRANSFORMS_SELECTION } from '../../../common/constants';

export interface TransformSelectorControlProps {
Expand All @@ -33,6 +34,8 @@ export const TransformSelectorControl: FC<TransformSelectorControlProps> = ({
options,
allowSelectAll = false,
}) => {
const [allowCustomOptions, setAllowCustomOptions] = useState(false);

const onSelectionChange: EuiComboBoxProps<string>['onChange'] = ((selectionUpdate) => {
if (!selectionUpdate?.length) {
onChange([]);
Expand All @@ -50,6 +53,12 @@ export const TransformSelectorControl: FC<TransformSelectorControlProps> = ({
);
}) as Exclude<EuiComboBoxProps<string>['onChange'], undefined>;

const onCreateOption = allowCustomOptions
? (((searchValue) => {
onChange([...selectedOptions, searchValue]);
}) as EuiComboBoxOptionsListProps<string>['onCreateOption'])
: undefined;

const selectedOptionsEui = useMemo(() => convertToEuiOptions(selectedOptions), [selectedOptions]);
const optionsEui = useMemo(() => {
return convertToEuiOptions(allowSelectAll ? [ALL_TRANSFORMS_SELECTION, ...options] : options);
Expand All @@ -58,6 +67,17 @@ export const TransformSelectorControl: FC<TransformSelectorControlProps> = ({
return (
<EuiFormRow fullWidth label={label} isInvalid={!!errors?.length} error={errors}>
<EuiComboBox<string>
onSearchChange={(searchValue, hasMatchingOption) => {
setAllowCustomOptions(!hasMatchingOption && searchValue.includes('*'));
}}
onCreateOption={onCreateOption}
customOptionText={i18n.translate(
'xpack.transform.alertTypes.transformHealth.customOptionText',
{
defaultMessage: 'Include {searchValuePlaceholder} wildcard',
values: { searchValuePlaceholder: '{searchValue}' },
}
)}
singleSelection={false}
selectedOptions={selectedOptionsEui}
options={optionsEui}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ interface Props {
}

export const ExpandedRowJsonPane: FC<Props> = ({ json }) => {
// exclude alerting rules from the JSON
if ('alerting_rules' in json) {
const { alerting_rules: alertingRules, ...rest } = json;
json = rest;
}

return (
<div data-test-subj="transformJsonTabContent">
<EuiFlexGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
* 2.0.
*/

import { transformHealthServiceProvider } from './transform_health_service';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { rulesClientMock } from '@kbn/alerting-plugin/server/rules_client.mock';
import type {
TransformGetTransformResponse,
TransformGetTransformStatsResponse,
TransformGetTransformTransformSummary,
} from '@elastic/elasticsearch/lib/api/types';
import type { FindResult, RulesClient } from '@kbn/alerting-plugin/server';
import { rulesClientMock } from '@kbn/alerting-plugin/server/rules_client.mock';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { transformHealthServiceProvider } from './transform_health_service';
import type { TransformHealthRuleParams } from './schema';

describe('transformHealthServiceProvider', () => {
let esClient: jest.Mocked<ElasticsearchClient>;
Expand All @@ -24,20 +26,48 @@ describe('transformHealthServiceProvider', () => {
beforeEach(() => {
esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;

(esClient.transform.getTransform as jest.Mock).mockResolvedValue({
count: 3,
transforms: [
// Mock continuous transforms
...new Array(102).fill(null).map((_, i) => ({
id: `transform${i}`,
sync: true,
})),
{
id: 'transform102',
sync: false,
},
],
} as unknown as TransformGetTransformResponse);
(esClient.transform.getTransform as jest.Mock).mockImplementation(
async ({ transform_id: transformId }) => {
if (transformId === 'transform4,transform6,transform6*') {
// arrangement for exclude transforms
return {
transforms: [
{
id: `transform4`,
sync: true,
},
{
id: `transform6`,
sync: true,
},
...new Array(10).fill(null).map((_, i) => ({
id: `transform6${i}`,
sync: true,
})),
],
} as unknown as TransformGetTransformResponse;
} else {
return {
transforms: [
// Mock continuous transforms
...new Array(102).fill(null).map((_, i) => ({
id: `transform${i}`,
sync: {
time: {
field: 'order_date',
delay: '60s',
},
},
})),
{
id: 'transform102',
},
],
} as unknown as TransformGetTransformResponse;
}
}
);

(esClient.transform.getTransformStats as jest.Mock).mockResolvedValue({
count: 2,
transforms: [{}],
Expand All @@ -57,19 +87,27 @@ describe('transformHealthServiceProvider', () => {
const service = transformHealthServiceProvider({ esClient, rulesClient, fieldFormatsRegistry });
const result = await service.getHealthChecksResults({
includeTransforms: ['*'],
excludeTransforms: ['transform4', 'transform6', 'transform62'],
excludeTransforms: ['transform4', 'transform6', 'transform6*'],
testsConfig: null,
});

expect(esClient.transform.getTransform).toHaveBeenCalledTimes(2);

expect(esClient.transform.getTransform).toHaveBeenCalledWith({
allow_no_match: true,
size: 1000,
});
expect(esClient.transform.getTransform).toHaveBeenCalledWith({
transform_id: 'transform4,transform6,transform6*',
allow_no_match: true,
size: 1000,
});

expect(esClient.transform.getTransformStats).toHaveBeenCalledTimes(1);
expect(esClient.transform.getTransformStats).toHaveBeenNthCalledWith(1, {
basic: true,
transform_id:
'transform0,transform1,transform2,transform3,transform5,transform7,transform8,transform9,transform10,transform11,transform12,transform13,transform14,transform15,transform16,transform17,transform18,transform19,transform20,transform21,transform22,transform23,transform24,transform25,transform26,transform27,transform28,transform29,transform30,transform31,transform32,transform33,transform34,transform35,transform36,transform37,transform38,transform39,transform40,transform41,transform42,transform43,transform44,transform45,transform46,transform47,transform48,transform49,transform50,transform51,transform52,transform53,transform54,transform55,transform56,transform57,transform58,transform59,transform60,transform61,transform63,transform64,transform65,transform66,transform67,transform68,transform69,transform70,transform71,transform72,transform73,transform74,transform75,transform76,transform77,transform78,transform79,transform80,transform81,transform82,transform83,transform84,transform85,transform86,transform87,transform88,transform89,transform90,transform91,transform92,transform93,transform94,transform95,transform96,transform97,transform98,transform99,transform100,transform101',
'transform0,transform1,transform2,transform3,transform5,transform7,transform8,transform9,transform10,transform11,transform12,transform13,transform14,transform15,transform16,transform17,transform18,transform19,transform20,transform21,transform22,transform23,transform24,transform25,transform26,transform27,transform28,transform29,transform30,transform31,transform32,transform33,transform34,transform35,transform36,transform37,transform38,transform39,transform40,transform41,transform42,transform43,transform44,transform45,transform46,transform47,transform48,transform49,transform50,transform51,transform52,transform53,transform54,transform55,transform56,transform57,transform58,transform59,transform70,transform71,transform72,transform73,transform74,transform75,transform76,transform77,transform78,transform79,transform80,transform81,transform82,transform83,transform84,transform85,transform86,transform87,transform88,transform89,transform90,transform91,transform92,transform93,transform94,transform95,transform96,transform97,transform98,transform99,transform100,transform101',
});

expect(result).toBeDefined();
Expand Down Expand Up @@ -126,4 +164,131 @@ describe('transformHealthServiceProvider', () => {
'Transform transform_with_a_very_long_id_that_result_in_long_url_for_sure_0, transform_with_a_very_long_id_that_result_in_long_url_for_sure_1, transform_with_a_very_long_id_that_result_in_long_url_for_sure_2, transform_with_a_very_long_id_that_result_in_long_url_for_sure_3, transform_with_a_very_long_id_that_result_in_long_url_for_sure_4, transform_with_a_very_long_id_that_result_in_long_url_for_sure_5, transform_with_a_very_long_id_that_result_in_long_url_for_sure_6, transform_with_a_very_long_id_that_result_in_long_url_for_sure_7, transform_with_a_very_long_id_that_result_in_long_url_for_sure_8, transform_with_a_very_long_id_that_result_in_long_url_for_sure_9, transform_with_a_very_long_id_that_result_in_long_url_for_sure_10, transform_with_a_very_long_id_that_result_in_long_url_for_sure_11, transform_with_a_very_long_id_that_result_in_long_url_for_sure_12, transform_with_a_very_long_id_that_result_in_long_url_for_sure_13, transform_with_a_very_long_id_that_result_in_long_url_for_sure_14, transform_with_a_very_long_id_that_result_in_long_url_for_sure_15, transform_with_a_very_long_id_that_result_in_long_url_for_sure_16, transform_with_a_very_long_id_that_result_in_long_url_for_sure_17, transform_with_a_very_long_id_that_result_in_long_url_for_sure_18, transform_with_a_very_long_id_that_result_in_long_url_for_sure_19, transform_with_a_very_long_id_that_result_in_long_url_for_sure_20, transform_with_a_very_long_id_that_result_in_long_url_for_sure_21, transform_with_a_very_long_id_that_result_in_long_url_for_sure_22, transform_with_a_very_long_id_that_result_in_long_url_for_sure_23, transform_with_a_very_long_id_that_result_in_long_url_for_sure_24, transform_with_a_very_long_id_that_result_in_long_url_for_sure_25, transform_with_a_very_long_id_that_result_in_long_url_for_sure_26, transform_with_a_very_long_id_that_result_in_long_url_for_sure_27, transform_with_a_very_long_id_that_result_in_long_url_for_sure_28, transform_with_a_very_long_id_that_result_in_long_url_for_sure_29, transform_with_a_very_long_id_that_result_in_long_url_for_sure_30, transform_with_a_very_long_id_that_result_in_long_url_for_sure_31, transform_with_a_very_long_id_that_result_in_long_url_for_sure_32, transform_with_a_very_long_id_that_result_in_long_url_for_sure_33, transform_with_a_very_long_id_that_result_in_long_url_for_sure_34, transform_with_a_very_long_id_that_result_in_long_url_for_sure_35, transform_with_a_very_long_id_that_result_in_long_url_for_sure_36, transform_with_a_very_long_id_that_result_in_long_url_for_sure_37, transform_with_a_very_long_id_that_result_in_long_url_for_sure_38, transform_with_a_very_long_id_that_result_in_long_url_for_sure_39, transform_with_a_very_long_id_that_result_in_long_url_for_sure_40, transform_with_a_very_long_id_that_result_in_long_url_for_sure_41, transform_with_a_very_long_id_that_result_in_long_url_for_sure_42, transform_with_a_very_long_id_that_result_in_long_url_for_sure_43, transform_with_a_very_long_id_that_result_in_long_url_for_sure_44, transform_with_a_very_long_id_that_result_in_long_url_for_sure_45, transform_with_a_very_long_id_that_result_in_long_url_for_sure_46, transform_with_a_very_long_id_that_result_in_long_url_for_sure_47, transform_with_a_very_long_id_that_result_in_long_url_for_sure_48, transform_with_a_very_long_id_that_result_in_long_url_for_sure_49, transform_with_a_very_long_id_that_result_in_long_url_for_sure_50, transform_with_a_very_long_id_that_result_in_long_url_for_sure_51, transform_with_a_very_long_id_that_result_in_long_url_for_sure_52, transform_with_a_very_long_id_that_result_in_long_url_for_sure_53, transform_with_a_very_long_id_that_result_in_long_url_for_sure_54, transform_with_a_very_long_id_that_result_in_long_url_for_sure_55, transform_with_a_very_long_id_that_result_in_long_url_for_sure_56, transform_with_a_very_long_id_that_result_in_long_url_for_sure_57, transform_with_a_very_long_id_that_result_in_long_url_for_sure_58, transform_with_a_very_long_id_that_result_in_long_url_for_sure_59 are not started.'
);
});

describe('populateTransformsWithAssignedRules', () => {
it('should throw an error if rulesClient is missing', async () => {
const service = transformHealthServiceProvider({ esClient, fieldFormatsRegistry });

await expect(service.populateTransformsWithAssignedRules([])).rejects.toThrow(
'Rules client is missing'
);
});

it('should return an empty list if no transforms are provided', async () => {
const service = transformHealthServiceProvider({
esClient,
rulesClient,
fieldFormatsRegistry,
});

const result = await service.populateTransformsWithAssignedRules([]);
expect(result).toEqual([]);
});

it('should return transforms with associated alerting rules', async () => {
const transforms = [
{ id: 'transform1', sync: {} },
{ id: 'transform2', sync: {} },
{ id: 'transform3', sync: {} },
] as TransformGetTransformTransformSummary[];

const rules = [
{
id: 'rule1',
params: {
includeTransforms: ['transform1', 'transform2'],
excludeTransforms: [],
},
},
{
id: 'rule2',
params: {
includeTransforms: ['transform3'],
excludeTransforms: null,
},
},
];

rulesClient.find.mockResolvedValue({ data: rules } as FindResult<TransformHealthRuleParams>);

const service = transformHealthServiceProvider({
esClient,
rulesClient,
fieldFormatsRegistry,
});

const result = await service.populateTransformsWithAssignedRules(transforms);

expect(result).toEqual([
{
id: 'transform1',
sync: {},
alerting_rules: [rules[0]],
},
{
id: 'transform2',
sync: {},
alerting_rules: [rules[0]],
},
{
id: 'transform3',
sync: {},
alerting_rules: [rules[1]],
},
]);
});

it('should exclude transforms based on excludeTransforms parameter', async () => {
const transforms = [
{ id: 'transform1', sync: {} },
{ id: 'transform2', sync: {} },
{ id: 'transform3', sync: {} },
] as TransformGetTransformTransformSummary[];

const rules = [
{
id: 'rule1',
params: {
includeTransforms: ['transform*'],
excludeTransforms: ['transform2'],
},
},
{
id: 'rule2',
params: {
includeTransforms: ['*'],
excludeTransforms: [],
},
},
];

rulesClient.find.mockResolvedValue({ data: rules } as FindResult<TransformHealthRuleParams>);

const service = transformHealthServiceProvider({
esClient,
rulesClient,
fieldFormatsRegistry,
});

const result = await service.populateTransformsWithAssignedRules(transforms);

expect(result).toEqual([
{
id: 'transform1',
sync: {},
alerting_rules: [rules[0], rules[1]],
},
{
id: 'transform2',
sync: {},
alerting_rules: [rules[1]],
},
{
id: 'transform3',
sync: {},
alerting_rules: [rules[0], rules[1]],
},
]);
});
});
});
Loading

0 comments on commit fd98643

Please sign in to comment.