mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[ResponseOps][Alerting] ESQL alerting rule type ( pointed at main) (#165480)
Resolves https://github.com/elastic/kibana/issues/153448 ## Summary The same as this [pr](https://github.com/elastic/kibana/pull/164073), but just points at main --------- Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co> Co-authored-by: Alexey Antonov <alexwizp@gmail.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Abdon Pijpelink <abdon.pijpelink@elastic.co> Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: Peter Pisljar <peter.pisljar@elastic.co> Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
This commit is contained in:
parent
756599f7c2
commit
5f00ae97dd
45 changed files with 2241 additions and 129 deletions
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { TextBasedLanguagesEditorProps } from './src/text_based_languages_editor';
|
export type { TextBasedLanguagesEditorProps } from './src/text_based_languages_editor';
|
||||||
|
export { fetchFieldsFromESQL } from './src/fetch_fields_from_esql';
|
||||||
import { TextBasedLanguagesEditor } from './src/text_based_languages_editor';
|
import { TextBasedLanguagesEditor } from './src/text_based_languages_editor';
|
||||||
|
|
||||||
// React.lazy support
|
// React.lazy support
|
||||||
|
|
|
@ -155,6 +155,7 @@ interface EditorFooterProps {
|
||||||
detectTimestamp: boolean;
|
detectTimestamp: boolean;
|
||||||
onErrorClick: (error: MonacoError) => void;
|
onErrorClick: (error: MonacoError) => void;
|
||||||
refreshErrors: () => void;
|
refreshErrors: () => void;
|
||||||
|
hideRunQueryText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorFooter = memo(function EditorFooter({
|
export const EditorFooter = memo(function EditorFooter({
|
||||||
|
@ -165,6 +166,7 @@ export const EditorFooter = memo(function EditorFooter({
|
||||||
detectTimestamp,
|
detectTimestamp,
|
||||||
onErrorClick,
|
onErrorClick,
|
||||||
refreshErrors,
|
refreshErrors,
|
||||||
|
hideRunQueryText,
|
||||||
}: EditorFooterProps) {
|
}: EditorFooterProps) {
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
|
@ -235,27 +237,29 @@ export const EditorFooter = memo(function EditorFooter({
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
<EuiFlexItem grow={false}>
|
{!hideRunQueryText && (
|
||||||
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">
|
<EuiFlexItem grow={false}>
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">
|
||||||
<EuiText size="xs" color="subdued">
|
<EuiFlexItem grow={false}>
|
||||||
<p>
|
<EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-run-query">
|
||||||
{i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', {
|
<p>
|
||||||
defaultMessage: 'Run query',
|
{i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', {
|
||||||
})}
|
defaultMessage: 'Run query',
|
||||||
</p>
|
})}
|
||||||
</EuiText>
|
</p>
|
||||||
</EuiFlexItem>
|
</EuiText>
|
||||||
<EuiFlexItem grow={false}>
|
</EuiFlexItem>
|
||||||
<EuiCode
|
<EuiFlexItem grow={false}>
|
||||||
transparentBackground
|
<EuiCode
|
||||||
css={css`
|
transparentBackground
|
||||||
font-size: 12px;
|
css={css`
|
||||||
`}
|
font-size: 12px;
|
||||||
>{`${COMMAND_KEY} + Enter`}</EuiCode>
|
`}
|
||||||
</EuiFlexItem>
|
>{`${COMMAND_KEY} + Enter`}</EuiCode>
|
||||||
</EuiFlexGroup>
|
</EuiFlexItem>
|
||||||
</EuiFlexItem>
|
</EuiFlexGroup>
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import { pluck } from 'rxjs/operators';
|
import { pluck } from 'rxjs/operators';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { Query, AggregateQuery } from '@kbn/es-query';
|
import { Query, AggregateQuery, TimeRange } from '@kbn/es-query';
|
||||||
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||||
import type { Datatable } from '@kbn/expressions-plugin/public';
|
import type { Datatable } from '@kbn/expressions-plugin/public';
|
||||||
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
|
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
|
||||||
|
@ -20,9 +20,14 @@ interface TextBasedLanguagesErrorResponse {
|
||||||
type: 'error';
|
type: 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchFieldsFromESQL(query: Query | AggregateQuery, expressions: ExpressionsStart) {
|
export function fetchFieldsFromESQL(
|
||||||
|
query: Query | AggregateQuery,
|
||||||
|
expressions: ExpressionsStart,
|
||||||
|
time?: TimeRange
|
||||||
|
) {
|
||||||
return textBasedQueryStateToAstWithValidation({
|
return textBasedQueryStateToAstWithValidation({
|
||||||
query,
|
query,
|
||||||
|
time,
|
||||||
})
|
})
|
||||||
.then((ast) => {
|
.then((ast) => {
|
||||||
if (ast) {
|
if (ast) {
|
||||||
|
|
|
@ -242,4 +242,27 @@ describe('TextBasedLanguagesEditor', () => {
|
||||||
).toBe('1 line');
|
).toBe('1 line');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render the run query text', async () => {
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
isCodeEditorExpanded: true,
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
|
||||||
|
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).not.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render the run query text if the hideRunQueryText prop is set to true', async () => {
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
isCodeEditorExpanded: true,
|
||||||
|
hideRunQueryText: true,
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
|
||||||
|
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -75,6 +75,7 @@ export interface TextBasedLanguagesEditorProps {
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
dataTestSubj?: string;
|
dataTestSubj?: string;
|
||||||
hideMinimizeButton?: boolean;
|
hideMinimizeButton?: boolean;
|
||||||
|
hideRunQueryText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextBasedEditorDeps {
|
interface TextBasedEditorDeps {
|
||||||
|
@ -120,6 +121,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
|
||||||
isDisabled,
|
isDisabled,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
hideMinimizeButton,
|
hideMinimizeButton,
|
||||||
|
hideRunQueryText,
|
||||||
dataTestSubj,
|
dataTestSubj,
|
||||||
}: TextBasedLanguagesEditorProps) {
|
}: TextBasedLanguagesEditorProps) {
|
||||||
const { euiTheme } = useEuiTheme();
|
const { euiTheme } = useEuiTheme();
|
||||||
|
@ -781,6 +783,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
|
||||||
onErrorClick={onErrorClick}
|
onErrorClick={onErrorClick}
|
||||||
refreshErrors={onTextLangQuerySubmit}
|
refreshErrors={onTextLangQuerySubmit}
|
||||||
detectTimestamp={detectTimestamp}
|
detectTimestamp={detectTimestamp}
|
||||||
|
hideRunQueryText={hideRunQueryText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCodeEditorExpanded && (
|
{isCodeEditorExpanded && (
|
||||||
|
|
95
x-pack/plugins/stack_alerts/common/esql_query_utils.test.ts
Normal file
95
x-pack/plugins/stack_alerts/common/esql_query_utils.test.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
* 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 { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from './esql_query_utils';
|
||||||
|
|
||||||
|
describe('ESQL query utils', () => {
|
||||||
|
describe('rowToDocument', () => {
|
||||||
|
it('correctly converts ESQL row to document', () => {
|
||||||
|
expect(
|
||||||
|
rowToDocument(
|
||||||
|
[
|
||||||
|
{ name: '@timestamp', type: 'date' },
|
||||||
|
{ name: 'ecs.version', type: 'keyword' },
|
||||||
|
{ name: 'error.code', type: 'keyword' },
|
||||||
|
],
|
||||||
|
['2023-07-12T13:32:04.174Z', '1.8.0', null]
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
'@timestamp': '2023-07-12T13:32:04.174Z',
|
||||||
|
'ecs.version': '1.8.0',
|
||||||
|
'error.code': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toEsQueryHits', () => {
|
||||||
|
it('correctly converts ESQL table to ES query hits', () => {
|
||||||
|
expect(
|
||||||
|
toEsQueryHits({
|
||||||
|
columns: [
|
||||||
|
{ name: '@timestamp', type: 'date' },
|
||||||
|
{ name: 'ecs.version', type: 'keyword' },
|
||||||
|
{ name: 'error.code', type: 'keyword' },
|
||||||
|
],
|
||||||
|
values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]],
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
_id: 'esql_query_document',
|
||||||
|
_index: '',
|
||||||
|
_source: {
|
||||||
|
'@timestamp': '2023-07-12T13:32:04.174Z',
|
||||||
|
'ecs.version': '1.8.0',
|
||||||
|
'error.code': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transformDatatableToEsqlTable', () => {
|
||||||
|
it('correctly converts data table to ESQL table', () => {
|
||||||
|
expect(
|
||||||
|
transformDatatableToEsqlTable({
|
||||||
|
type: 'datatable',
|
||||||
|
columns: [
|
||||||
|
{ id: '@timestamp', name: '@timestamp', meta: { type: 'date' } },
|
||||||
|
{ id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } },
|
||||||
|
{ id: 'error.code', name: 'error.code', meta: { type: 'string' } },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
'@timestamp': '2023-07-12T13:32:04.174Z',
|
||||||
|
'ecs.version': '1.8.0',
|
||||||
|
'error.code': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: '@timestamp',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ecs.version',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'error.code',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
63
x-pack/plugins/stack_alerts/common/esql_query_utils.ts
Normal file
63
x-pack/plugins/stack_alerts/common/esql_query_utils.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* 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 { Datatable } from '@kbn/expressions-plugin/common';
|
||||||
|
|
||||||
|
type EsqlDocument = Record<string, string | null>;
|
||||||
|
|
||||||
|
interface EsqlHit {
|
||||||
|
_id: string;
|
||||||
|
_index: string;
|
||||||
|
_source: EsqlDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EsqlResultColumn {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EsqlResultRow = Array<string | null>;
|
||||||
|
|
||||||
|
export interface EsqlTable {
|
||||||
|
columns: EsqlResultColumn[];
|
||||||
|
values: EsqlResultRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESQL_DOCUMENT_ID = 'esql_query_document';
|
||||||
|
|
||||||
|
export const rowToDocument = (columns: EsqlResultColumn[], row: EsqlResultRow): EsqlDocument => {
|
||||||
|
return columns.reduce<Record<string, string | null>>((acc, column, i) => {
|
||||||
|
acc[column.name] = row[i];
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toEsQueryHits = (results: EsqlTable) => {
|
||||||
|
const hits: EsqlHit[] = results.values.map((row) => {
|
||||||
|
const document = rowToDocument(results.columns, row);
|
||||||
|
return {
|
||||||
|
_id: ESQL_DOCUMENT_ID,
|
||||||
|
_index: '',
|
||||||
|
_source: document,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits,
|
||||||
|
total: hits.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformDatatableToEsqlTable = (results: Datatable): EsqlTable => {
|
||||||
|
const columns: EsqlResultColumn[] = results.columns.map((c) => ({
|
||||||
|
name: c.id,
|
||||||
|
type: c.meta.type,
|
||||||
|
}));
|
||||||
|
const values: EsqlResultRow[] = results.rows.map((r) => Object.values(r));
|
||||||
|
return { columns, values };
|
||||||
|
};
|
|
@ -12,3 +12,6 @@ export {
|
||||||
getHumanReadableComparator,
|
getHumanReadableComparator,
|
||||||
} from './comparator';
|
} from './comparator';
|
||||||
export { STACK_ALERTS_FEATURE_ID } from './constants';
|
export { STACK_ALERTS_FEATURE_ID } from './constants';
|
||||||
|
|
||||||
|
export type { EsqlTable } from './esql_query_utils';
|
||||||
|
export { rowToDocument, transformDatatableToEsqlTable, toEsQueryHits } from './esql_query_utils';
|
||||||
|
|
|
@ -22,7 +22,8 @@
|
||||||
"kibanaUtils"
|
"kibanaUtils"
|
||||||
],
|
],
|
||||||
"requiredBundles": [
|
"requiredBundles": [
|
||||||
"esUiShared"
|
"esUiShared",
|
||||||
|
"textBasedLanguages"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,13 @@ export const ONLY_ES_QUERY_EXPRESSION_ERRORS = {
|
||||||
timeField: new Array<string>(),
|
timeField: new Array<string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ONLY_ESQL_QUERY_EXPRESSION_ERRORS = {
|
||||||
|
esqlQuery: new Array<string>(),
|
||||||
|
timeField: new Array<string>(),
|
||||||
|
thresholdComparator: new Array<string>(),
|
||||||
|
threshold0: new Array<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
const ALL_EXPRESSION_ERROR_ENTRIES = {
|
const ALL_EXPRESSION_ERROR_ENTRIES = {
|
||||||
...COMMON_EXPRESSION_ERRORS,
|
...COMMON_EXPRESSION_ERRORS,
|
||||||
...SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,
|
...SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,
|
||||||
|
|
|
@ -0,0 +1,196 @@
|
||||||
|
/*
|
||||||
|
* 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 { fireEvent, render, waitFor } from '@testing-library/react';
|
||||||
|
import { I18nProvider } from '@kbn/i18n-react';
|
||||||
|
|
||||||
|
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||||
|
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||||
|
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
|
||||||
|
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
||||||
|
import { EsqlQueryExpression } from './esql_query_expression';
|
||||||
|
import { EsQueryRuleParams, SearchType } from '../types';
|
||||||
|
|
||||||
|
jest.mock('../validation', () => ({
|
||||||
|
hasExpressionValidationErrors: jest.fn(),
|
||||||
|
}));
|
||||||
|
const { hasExpressionValidationErrors } = jest.requireMock('../validation');
|
||||||
|
|
||||||
|
jest.mock('@kbn/text-based-editor', () => ({
|
||||||
|
fetchFieldsFromESQL: jest.fn(),
|
||||||
|
}));
|
||||||
|
const { fetchFieldsFromESQL } = jest.requireMock('@kbn/text-based-editor');
|
||||||
|
|
||||||
|
const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => (
|
||||||
|
<I18nProvider>{children}</I18nProvider>
|
||||||
|
));
|
||||||
|
|
||||||
|
const dataMock = dataPluginMock.createStartContract();
|
||||||
|
const dataViewMock = dataViewPluginMocks.createStartContract();
|
||||||
|
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
|
||||||
|
const chartsStartMock = chartPluginMock.createStartContract();
|
||||||
|
|
||||||
|
const defaultEsqlQueryExpressionParams: EsQueryRuleParams<SearchType.esqlQuery> = {
|
||||||
|
size: 100,
|
||||||
|
thresholdComparator: '>',
|
||||||
|
threshold: [0],
|
||||||
|
timeWindowSize: 15,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
index: ['test-index'],
|
||||||
|
timeField: '@timestamp',
|
||||||
|
aggType: 'count',
|
||||||
|
groupBy: 'all',
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
esqlQuery: { esql: '' },
|
||||||
|
excludeHitsFromPreviousRun: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EsqlQueryRuleTypeExpression', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
hasExpressionValidationErrors.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render EsqlQueryRuleTypeExpression with expected components', () => {
|
||||||
|
const result = render(
|
||||||
|
<EsqlQueryExpression
|
||||||
|
unifiedSearch={unifiedSearchMock}
|
||||||
|
ruleInterval="1m"
|
||||||
|
ruleThrottle="1m"
|
||||||
|
alertNotifyWhen="onThrottleInterval"
|
||||||
|
ruleParams={defaultEsqlQueryExpressionParams}
|
||||||
|
setRuleParams={() => {}}
|
||||||
|
setRuleProperty={() => {}}
|
||||||
|
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
|
||||||
|
data={dataMock}
|
||||||
|
dataViews={dataViewMock}
|
||||||
|
defaultActionGroupId=""
|
||||||
|
actionGroups={[]}
|
||||||
|
charts={chartsStartMock}
|
||||||
|
onChangeMetaData={() => {}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.getByTestId('queryEsqlEditor')).toBeInTheDocument();
|
||||||
|
expect(result.getByTestId('timeFieldSelect')).toBeInTheDocument();
|
||||||
|
expect(result.getByTestId('timeWindowSizeNumber')).toBeInTheDocument();
|
||||||
|
expect(result.getByTestId('timeWindowUnitSelect')).toBeInTheDocument();
|
||||||
|
expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument();
|
||||||
|
expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render Test Query button disabled if alert params are invalid', async () => {
|
||||||
|
hasExpressionValidationErrors.mockReturnValue(true);
|
||||||
|
const result = render(
|
||||||
|
<EsqlQueryExpression
|
||||||
|
unifiedSearch={unifiedSearchMock}
|
||||||
|
ruleInterval="1m"
|
||||||
|
ruleThrottle="1m"
|
||||||
|
alertNotifyWhen="onThrottleInterval"
|
||||||
|
ruleParams={defaultEsqlQueryExpressionParams}
|
||||||
|
setRuleParams={() => {}}
|
||||||
|
setRuleProperty={() => {}}
|
||||||
|
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
|
||||||
|
data={dataMock}
|
||||||
|
dataViews={dataViewMock}
|
||||||
|
defaultActionGroupId=""
|
||||||
|
actionGroups={[]}
|
||||||
|
charts={chartsStartMock}
|
||||||
|
onChangeMetaData={() => {}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = result.getByTestId('testQuery');
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show success message if Test Query is successful', async () => {
|
||||||
|
fetchFieldsFromESQL.mockResolvedValue({
|
||||||
|
type: 'datatable',
|
||||||
|
columns: [
|
||||||
|
{ id: '@timestamp', name: '@timestamp', meta: { type: 'date' } },
|
||||||
|
{ id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } },
|
||||||
|
{ id: 'error.code', name: 'error.code', meta: { type: 'string' } },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
'@timestamp': '2023-07-12T13:32:04.174Z',
|
||||||
|
'ecs.version': '1.8.0',
|
||||||
|
'error.code': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = render(
|
||||||
|
<EsqlQueryExpression
|
||||||
|
unifiedSearch={unifiedSearchMock}
|
||||||
|
ruleInterval="1m"
|
||||||
|
ruleThrottle="1m"
|
||||||
|
alertNotifyWhen="onThrottleInterval"
|
||||||
|
ruleParams={defaultEsqlQueryExpressionParams}
|
||||||
|
setRuleParams={() => {}}
|
||||||
|
setRuleProperty={() => {}}
|
||||||
|
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
|
||||||
|
data={dataMock}
|
||||||
|
dataViews={dataViewMock}
|
||||||
|
defaultActionGroupId=""
|
||||||
|
actionGroups={[]}
|
||||||
|
charts={chartsStartMock}
|
||||||
|
onChangeMetaData={() => {}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(result.getByTestId('testQuery'));
|
||||||
|
await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled());
|
||||||
|
|
||||||
|
expect(result.getByTestId('testQuerySuccess')).toBeInTheDocument();
|
||||||
|
expect(result.getByText('Query matched 1 documents in the last 15s.')).toBeInTheDocument();
|
||||||
|
expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error message if Test Query is throws error', async () => {
|
||||||
|
fetchFieldsFromESQL.mockRejectedValue('Error getting test results.!');
|
||||||
|
const result = render(
|
||||||
|
<EsqlQueryExpression
|
||||||
|
unifiedSearch={unifiedSearchMock}
|
||||||
|
ruleInterval="1m"
|
||||||
|
ruleThrottle="1m"
|
||||||
|
alertNotifyWhen="onThrottleInterval"
|
||||||
|
ruleParams={defaultEsqlQueryExpressionParams}
|
||||||
|
setRuleParams={() => {}}
|
||||||
|
setRuleProperty={() => {}}
|
||||||
|
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
|
||||||
|
data={dataMock}
|
||||||
|
dataViews={dataViewMock}
|
||||||
|
defaultActionGroupId=""
|
||||||
|
actionGroups={[]}
|
||||||
|
charts={chartsStartMock}
|
||||||
|
onChangeMetaData={() => {}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(result.getByTestId('testQuery'));
|
||||||
|
await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled());
|
||||||
|
|
||||||
|
expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument();
|
||||||
|
expect(result.getByTestId('testQueryError')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,255 @@
|
||||||
|
/*
|
||||||
|
* 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, { useState, Fragment, useEffect, useCallback } from 'react';
|
||||||
|
import { get } from 'lodash';
|
||||||
|
import { FormattedMessage } from '@kbn/i18n-react';
|
||||||
|
import {
|
||||||
|
EuiFieldNumber,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiFormRow,
|
||||||
|
EuiSelect,
|
||||||
|
EuiSpacer,
|
||||||
|
EuiTitle,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { getFields, RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
|
||||||
|
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
|
||||||
|
import { fetchFieldsFromESQL } from '@kbn/text-based-editor';
|
||||||
|
import { AggregateQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query';
|
||||||
|
import { parseDuration } from '@kbn/alerting-plugin/common';
|
||||||
|
import {
|
||||||
|
firstFieldOption,
|
||||||
|
getTimeFieldOptions,
|
||||||
|
getTimeOptions,
|
||||||
|
parseAggregationResults,
|
||||||
|
} from '@kbn/triggers-actions-ui-plugin/public/common';
|
||||||
|
import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
|
||||||
|
import { DEFAULT_VALUES } from '../constants';
|
||||||
|
import { useTriggerUiActionServices } from '../util';
|
||||||
|
import { hasExpressionValidationErrors } from '../validation';
|
||||||
|
import { TestQueryRow } from '../test_query_row';
|
||||||
|
import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from '../../../../common';
|
||||||
|
|
||||||
|
export const EsqlQueryExpression: React.FC<
|
||||||
|
RuleTypeParamsExpressionProps<EsQueryRuleParams<SearchType.esqlQuery>, EsQueryRuleMetaData>
|
||||||
|
> = ({ ruleParams, setRuleParams, setRuleProperty, errors }) => {
|
||||||
|
const { expressions, http } = useTriggerUiActionServices();
|
||||||
|
const { esqlQuery, timeWindowSize, timeWindowUnit, timeField } = ruleParams;
|
||||||
|
|
||||||
|
const [currentRuleParams, setCurrentRuleParams] = useState<
|
||||||
|
EsQueryRuleParams<SearchType.esqlQuery>
|
||||||
|
>({
|
||||||
|
...ruleParams,
|
||||||
|
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||||
|
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||||
|
// ESQL queries compare conditions within the ES query
|
||||||
|
// so only 'met' results are returned, therefore the threshold should always be 0
|
||||||
|
threshold: [0],
|
||||||
|
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||||
|
size: DEFAULT_VALUES.SIZE,
|
||||||
|
esqlQuery: esqlQuery ?? { esql: '' },
|
||||||
|
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
|
||||||
|
groupBy: DEFAULT_VALUES.GROUP_BY,
|
||||||
|
termSize: DEFAULT_VALUES.TERM_SIZE,
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
});
|
||||||
|
const [query, setQuery] = useState<AggregateQuery>({ esql: '' });
|
||||||
|
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
|
||||||
|
const [detectTimestamp, setDetectTimestamp] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const setParam = useCallback(
|
||||||
|
(paramField: string, paramValue: unknown) => {
|
||||||
|
setCurrentRuleParams((currentParams) => ({
|
||||||
|
...currentParams,
|
||||||
|
[paramField]: paramValue,
|
||||||
|
}));
|
||||||
|
setRuleParams(paramField, paramValue);
|
||||||
|
},
|
||||||
|
[setRuleParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDefaultExpressionValues = async () => {
|
||||||
|
setRuleProperty('params', currentRuleParams);
|
||||||
|
setQuery(esqlQuery ?? { esql: '' });
|
||||||
|
if (timeField) {
|
||||||
|
setTimeFieldOptions([firstFieldOption, { text: timeField, value: timeField }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
setDefaultExpressionValues();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTestQuery = useCallback(async () => {
|
||||||
|
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||||
|
const emptyResult = {
|
||||||
|
testResults: { results: [], truncated: false },
|
||||||
|
isGrouped: true,
|
||||||
|
timeWindow: window,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasExpressionValidationErrors(currentRuleParams)) {
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
const timeWindow = parseDuration(window);
|
||||||
|
const now = Date.now();
|
||||||
|
const table = await fetchFieldsFromESQL(esqlQuery, expressions, {
|
||||||
|
from: new Date(now - timeWindow).toISOString(),
|
||||||
|
to: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
if (table) {
|
||||||
|
const esqlTable = transformDatatableToEsqlTable(table);
|
||||||
|
const hits = toEsQueryHits(esqlTable);
|
||||||
|
return {
|
||||||
|
testResults: parseAggregationResults({
|
||||||
|
isCountAgg: true,
|
||||||
|
isGroupAgg: false,
|
||||||
|
esResult: {
|
||||||
|
took: 0,
|
||||||
|
timed_out: false,
|
||||||
|
_shards: { failed: 0, successful: 0, total: 0 },
|
||||||
|
hits,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isGrouped: false,
|
||||||
|
timeWindow: window,
|
||||||
|
rawResults: {
|
||||||
|
cols: esqlTable.columns.map((col) => ({
|
||||||
|
id: col.name,
|
||||||
|
})),
|
||||||
|
rows: esqlTable.values.slice(0, 5).map((row) => rowToDocument(esqlTable.columns, row)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return emptyResult;
|
||||||
|
}, [timeWindowSize, timeWindowUnit, currentRuleParams, esqlQuery, expressions]);
|
||||||
|
|
||||||
|
const refreshTimeFields = async (q: AggregateQuery) => {
|
||||||
|
let hasTimestamp = false;
|
||||||
|
const indexPattern: string = getIndexPatternFromESQLQuery(get(q, 'esql'));
|
||||||
|
const currentEsFields = await getFields(http, [indexPattern]);
|
||||||
|
const timeFields = getTimeFieldOptions(currentEsFields);
|
||||||
|
setTimeFieldOptions([firstFieldOption, ...timeFields]);
|
||||||
|
|
||||||
|
const timestampField = timeFields.find((field) => field.value === '@timestamp');
|
||||||
|
if (timestampField) {
|
||||||
|
setParam('timeField', timestampField.value);
|
||||||
|
hasTimestamp = true;
|
||||||
|
}
|
||||||
|
setDetectTimestamp(hasTimestamp);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<EuiTitle size="xs">
|
||||||
|
<h5>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.stackAlerts.esQuery.ui.defineEsqlQueryPrompt"
|
||||||
|
defaultMessage="Define your query using ESQL"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</EuiTitle>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<EuiFormRow id="queryEditor" data-test-subj="queryEsqlEditor" fullWidth>
|
||||||
|
<TextBasedLangEditor
|
||||||
|
query={query}
|
||||||
|
onTextLangQueryChange={(q: AggregateQuery) => {
|
||||||
|
setQuery(q);
|
||||||
|
setParam('esqlQuery', q);
|
||||||
|
refreshTimeFields(q);
|
||||||
|
}}
|
||||||
|
expandCodeEditor={() => true}
|
||||||
|
isCodeEditorExpanded={true}
|
||||||
|
onTextLangQuerySubmit={() => {}}
|
||||||
|
detectTimestamp={detectTimestamp}
|
||||||
|
hideMinimizeButton={true}
|
||||||
|
hideRunQueryText={true}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
<EuiSpacer />
|
||||||
|
<EuiTitle size="xs">
|
||||||
|
<h5>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.stackAlerts.esQuery.ui.selectEsqlQueryTimeFieldPrompt"
|
||||||
|
defaultMessage="Select a time field"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</EuiTitle>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<EuiFormRow
|
||||||
|
id="timeField"
|
||||||
|
fullWidth
|
||||||
|
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||||
|
error={errors.timeField}
|
||||||
|
>
|
||||||
|
<EuiSelect
|
||||||
|
options={timeFieldOptions}
|
||||||
|
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
|
||||||
|
fullWidth
|
||||||
|
name="timeField"
|
||||||
|
data-test-subj="timeFieldSelect"
|
||||||
|
value={timeField || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setParam('timeField', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
<EuiSpacer />
|
||||||
|
<EuiTitle size="xs">
|
||||||
|
<h5>
|
||||||
|
<FormattedMessage
|
||||||
|
id="xpack.stackAlerts.esQuery.ui.setEsqlQueryTimeWindowPrompt"
|
||||||
|
defaultMessage="Set the time window"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</EuiTitle>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<EuiFlexGroup>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiFormRow
|
||||||
|
id="timeWindowSize"
|
||||||
|
isInvalid={errors.timeWindowSize.length > 0}
|
||||||
|
error={errors.timeWindowSize}
|
||||||
|
>
|
||||||
|
<EuiFieldNumber
|
||||||
|
name="timeWindowSize"
|
||||||
|
data-test-subj="timeWindowSizeNumber"
|
||||||
|
isInvalid={errors.timeWindowSize.length > 0}
|
||||||
|
min={0}
|
||||||
|
value={timeWindowSize || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
const timeWindowSizeVal = value !== '' ? parseInt(value, 10) : undefined;
|
||||||
|
setParam('timeWindowSize', timeWindowSizeVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiFormRow id="timeWindowUnit">
|
||||||
|
<EuiSelect
|
||||||
|
name="timeWindowUnit"
|
||||||
|
data-test-subj="timeWindowUnitSelect"
|
||||||
|
value={timeWindowUnit}
|
||||||
|
onChange={(e) => {
|
||||||
|
setParam('timeWindowUnit', e.target.value);
|
||||||
|
}}
|
||||||
|
options={getTimeOptions(timeWindowSize ?? 1)}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
<EuiSpacer />
|
||||||
|
<TestQueryRow
|
||||||
|
fetch={onTestQuery}
|
||||||
|
hasValidationErrors={hasExpressionValidationErrors(currentRuleParams)}
|
||||||
|
showTable
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
|
@ -23,7 +23,6 @@ import { EsQueryRuleTypeExpression } from './expression';
|
||||||
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { ISearchSource } from '@kbn/data-plugin/common';
|
import { ISearchSource } from '@kbn/data-plugin/common';
|
||||||
import { IUiSettingsClient } from '@kbn/core/public';
|
|
||||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
@ -75,6 +74,20 @@ const defaultSearchSourceRuleParams: EsQueryRuleParams<SearchType.searchSource>
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultEsqlRuleParams: EsQueryRuleParams<SearchType.esqlQuery> = {
|
||||||
|
size: 100,
|
||||||
|
thresholdComparator: '>',
|
||||||
|
threshold: [0],
|
||||||
|
timeWindowSize: 15,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
index: ['test-index'],
|
||||||
|
timeField: '@timestamp',
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
esqlQuery: { esql: 'test' },
|
||||||
|
excludeHitsFromPreviousRun: true,
|
||||||
|
aggType: 'count',
|
||||||
|
};
|
||||||
|
|
||||||
const dataViewPluginMock = dataViewPluginMocks.createStartContract();
|
const dataViewPluginMock = dataViewPluginMocks.createStartContract();
|
||||||
const chartsStartMock = chartPluginMock.createStartContract();
|
const chartsStartMock = chartPluginMock.createStartContract();
|
||||||
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
|
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
|
||||||
|
@ -82,7 +95,7 @@ const httpMock = httpServiceMock.createStartContract();
|
||||||
const docLinksMock = docLinksServiceMock.createStartContract();
|
const docLinksMock = docLinksServiceMock.createStartContract();
|
||||||
export const uiSettingsMock = {
|
export const uiSettingsMock = {
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
} as unknown as IUiSettingsClient;
|
};
|
||||||
|
|
||||||
const mockSearchResult = new Subject();
|
const mockSearchResult = new Subject();
|
||||||
const searchSourceFieldsMock = {
|
const searchSourceFieldsMock = {
|
||||||
|
@ -150,7 +163,10 @@ dataMock.query.savedQueries.findSavedQueries = jest.fn(() =>
|
||||||
(httpMock.post as jest.Mock).mockImplementation(() => Promise.resolve({ fields: [] }));
|
(httpMock.post as jest.Mock).mockImplementation(() => Promise.resolve({ fields: [] }));
|
||||||
|
|
||||||
const Wrapper: React.FC<{
|
const Wrapper: React.FC<{
|
||||||
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>;
|
ruleParams:
|
||||||
|
| EsQueryRuleParams<SearchType.searchSource>
|
||||||
|
| EsQueryRuleParams<SearchType.esQuery>
|
||||||
|
| EsQueryRuleParams<SearchType.esqlQuery>;
|
||||||
metadata?: EsQueryRuleMetaData;
|
metadata?: EsQueryRuleMetaData;
|
||||||
}> = ({ ruleParams, metadata }) => {
|
}> = ({ ruleParams, metadata }) => {
|
||||||
const [currentRuleParams, setCurrentRuleParams] = useState<CommonEsQueryRuleParams>(ruleParams);
|
const [currentRuleParams, setCurrentRuleParams] = useState<CommonEsQueryRuleParams>(ruleParams);
|
||||||
|
@ -192,7 +208,10 @@ const Wrapper: React.FC<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = (
|
const setup = (
|
||||||
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>,
|
ruleParams:
|
||||||
|
| EsQueryRuleParams<SearchType.searchSource>
|
||||||
|
| EsQueryRuleParams<SearchType.esQuery>
|
||||||
|
| EsQueryRuleParams<SearchType.esqlQuery>,
|
||||||
metadata?: EsQueryRuleMetaData
|
metadata?: EsQueryRuleMetaData
|
||||||
) => {
|
) => {
|
||||||
return mountWithIntl(
|
return mountWithIntl(
|
||||||
|
@ -213,11 +232,28 @@ const setup = (
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('EsQueryRuleTypeExpression', () => {
|
describe('EsQueryRuleTypeExpression', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
uiSettingsMock.get.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('should render options by default', async () => {
|
test('should render options by default', async () => {
|
||||||
const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>);
|
const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>);
|
||||||
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
||||||
expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy();
|
expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy();
|
||||||
expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy();
|
expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeTruthy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide ESQL option when not enabled', async () => {
|
||||||
|
uiSettingsMock.get.mockReturnValueOnce(false);
|
||||||
|
const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>);
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeFalsy();
|
||||||
expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -257,6 +293,23 @@ describe('EsQueryRuleTypeExpression', () => {
|
||||||
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should switch to ESQL form type on selection and return back on cancel', async () => {
|
||||||
|
let wrapper = setup({} as EsQueryRuleParams<SearchType.searchSource>);
|
||||||
|
await act(async () => {
|
||||||
|
findTestSubject(wrapper, 'queryFormType_esqlQuery').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper = await wrapper.update();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeFalsy();
|
||||||
|
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findTestSubject(wrapper, 'queryFormTypeChooserCancel').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper = await wrapper.update();
|
||||||
|
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeFalsy();
|
||||||
|
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test('should render QueryDSL view without the form type chooser', async () => {
|
test('should render QueryDSL view without the form type chooser', async () => {
|
||||||
let wrapper: ReactWrapper;
|
let wrapper: ReactWrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -282,4 +335,19 @@ describe('EsQueryRuleTypeExpression', () => {
|
||||||
expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
||||||
expect(findTestSubject(wrapper!, 'selectDataViewExpression').exists()).toBeTruthy();
|
expect(findTestSubject(wrapper!, 'selectDataViewExpression').exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render ESQL view without the form type chooser', async () => {
|
||||||
|
let wrapper: ReactWrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = setup(defaultEsqlRuleParams, {
|
||||||
|
adHocDataViewList: [],
|
||||||
|
isManagementPage: false,
|
||||||
|
});
|
||||||
|
wrapper = await wrapper.update();
|
||||||
|
});
|
||||||
|
wrapper = await wrapper!.update();
|
||||||
|
expect(findTestSubject(wrapper!, 'queryFormTypeChooserTitle').exists()).toBeFalsy();
|
||||||
|
expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
|
||||||
|
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,8 +15,9 @@ import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
|
||||||
import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression';
|
import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression';
|
||||||
import { EsQueryExpression } from './es_query_expression';
|
import { EsQueryExpression } from './es_query_expression';
|
||||||
import { QueryFormTypeChooser } from './query_form_type_chooser';
|
import { QueryFormTypeChooser } from './query_form_type_chooser';
|
||||||
import { isSearchSourceRule } from '../util';
|
import { isEsqlQueryRule, isSearchSourceRule } from '../util';
|
||||||
import { ALL_EXPRESSION_ERROR_KEYS } from '../constants';
|
import { ALL_EXPRESSION_ERROR_KEYS } from '../constants';
|
||||||
|
import { EsqlQueryExpression } from './esql_query_expression';
|
||||||
|
|
||||||
function areSearchSourceExpressionPropsEqual(
|
function areSearchSourceExpressionPropsEqual(
|
||||||
prevProps: Readonly<PropsWithChildren<SearchSourceExpressionProps>>,
|
prevProps: Readonly<PropsWithChildren<SearchSourceExpressionProps>>,
|
||||||
|
@ -37,6 +38,7 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent<
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { ruleParams, errors, setRuleProperty, setRuleParams } = props;
|
const { ruleParams, errors, setRuleProperty, setRuleParams } = props;
|
||||||
const isSearchSource = isSearchSourceRule(ruleParams);
|
const isSearchSource = isSearchSourceRule(ruleParams);
|
||||||
|
const isEsqlQuery = isEsqlQueryRule(ruleParams);
|
||||||
// metadata provided only when open alert from Discover page
|
// metadata provided only when open alert from Discover page
|
||||||
const isManagementPage = props.metadata?.isManagementPage ?? true;
|
const isManagementPage = props.metadata?.isManagementPage ?? true;
|
||||||
|
|
||||||
|
@ -95,10 +97,14 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent<
|
||||||
<SearchSourceExpressionMemoized {...props} ruleParams={ruleParams} />
|
<SearchSourceExpressionMemoized {...props} ruleParams={ruleParams} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ruleParams.searchType && !isSearchSource && (
|
{ruleParams.searchType && !isSearchSource && !isEsqlQuery && (
|
||||||
<EsQueryExpression {...props} ruleParams={ruleParams} />
|
<EsQueryExpression {...props} ruleParams={ruleParams} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ruleParams.searchType && isEsqlQuery && (
|
||||||
|
<EsqlQueryExpression {...props} ruleParams={ruleParams} />
|
||||||
|
)}
|
||||||
|
|
||||||
<EuiHorizontalRule />
|
<EuiHorizontalRule />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
EuiButtonIcon,
|
EuiButtonIcon,
|
||||||
EuiFlexGroup,
|
EuiFlexGroup,
|
||||||
|
@ -19,39 +19,7 @@ import {
|
||||||
import { FormattedMessage } from '@kbn/i18n-react';
|
import { FormattedMessage } from '@kbn/i18n-react';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { SearchType } from '../types';
|
import { SearchType } from '../types';
|
||||||
|
import { useTriggerUiActionServices } from '../util';
|
||||||
const FORM_TYPE_ITEMS: Array<{ formType: SearchType; label: string; description: string }> = [
|
|
||||||
{
|
|
||||||
formType: SearchType.searchSource,
|
|
||||||
label: i18n.translate(
|
|
||||||
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel',
|
|
||||||
{
|
|
||||||
defaultMessage: 'KQL or Lucene',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
description: i18n.translate(
|
|
||||||
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription',
|
|
||||||
{
|
|
||||||
defaultMessage: 'Use KQL or Lucene to define a text-based query.',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
formType: SearchType.esQuery,
|
|
||||||
label: i18n.translate(
|
|
||||||
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel',
|
|
||||||
{
|
|
||||||
defaultMessage: 'Query DSL',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
description: i18n.translate(
|
|
||||||
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription',
|
|
||||||
{
|
|
||||||
defaultMessage: 'Use the Elasticsearch Query DSL to define a query.',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface QueryFormTypeProps {
|
export interface QueryFormTypeProps {
|
||||||
searchType: SearchType | null;
|
searchType: SearchType | null;
|
||||||
|
@ -62,8 +30,65 @@ export const QueryFormTypeChooser: React.FC<QueryFormTypeProps> = ({
|
||||||
searchType,
|
searchType,
|
||||||
onFormTypeSelect,
|
onFormTypeSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { uiSettings } = useTriggerUiActionServices();
|
||||||
|
const isEsqlEnabled = uiSettings?.get('discover:enableESQL');
|
||||||
|
|
||||||
|
const formTypeItems = useMemo(() => {
|
||||||
|
const items: Array<{ formType: SearchType; label: string; description: string }> = [
|
||||||
|
{
|
||||||
|
formType: SearchType.searchSource,
|
||||||
|
label: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel',
|
||||||
|
{
|
||||||
|
defaultMessage: 'KQL or Lucene',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
description: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Use KQL or Lucene to define a text-based query.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
formType: SearchType.esQuery,
|
||||||
|
label: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Query DSL',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
description: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Use the Elasticsearch Query DSL to define a query.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isEsqlEnabled) {
|
||||||
|
items.push({
|
||||||
|
formType: SearchType.esqlQuery,
|
||||||
|
label: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeLabel',
|
||||||
|
{
|
||||||
|
defaultMessage: 'ESQL',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
description: i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeDescription',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Use ESQL to define a text-based query.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [isEsqlEnabled]);
|
||||||
|
|
||||||
if (searchType) {
|
if (searchType) {
|
||||||
const activeFormTypeItem = FORM_TYPE_ITEMS.find((item) => item.formType === searchType);
|
const activeFormTypeItem = formTypeItems.find((item) => item.formType === searchType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -107,7 +132,7 @@ export const QueryFormTypeChooser: React.FC<QueryFormTypeProps> = ({
|
||||||
</h5>
|
</h5>
|
||||||
</EuiTitle>
|
</EuiTitle>
|
||||||
<EuiListGroup flush gutterSize="m" size="m" maxWidth={false}>
|
<EuiListGroup flush gutterSize="m" size="m" maxWidth={false}>
|
||||||
{FORM_TYPE_ITEMS.map((item) => (
|
{formTypeItems.map((item) => (
|
||||||
<EuiListGroupItem
|
<EuiListGroupItem
|
||||||
wrapText
|
wrapText
|
||||||
key={`form-type-${item.formType}`}
|
key={`form-type-${item.formType}`}
|
||||||
|
|
|
@ -12,12 +12,14 @@ import {
|
||||||
EuiFlexGroup,
|
EuiFlexGroup,
|
||||||
EuiFlexItem,
|
EuiFlexItem,
|
||||||
EuiFormRow,
|
EuiFormRow,
|
||||||
|
EuiSpacer,
|
||||||
EuiText,
|
EuiText,
|
||||||
EuiToolTip,
|
EuiToolTip,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import { FormattedMessage } from '@kbn/i18n-react';
|
import { FormattedMessage } from '@kbn/i18n-react';
|
||||||
import { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
|
import { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
|
||||||
import { useTestQuery } from './use_test_query';
|
import { useTestQuery } from './use_test_query';
|
||||||
|
import { TestQueryRowTable } from './test_query_row_table';
|
||||||
|
|
||||||
export interface TestQueryRowProps {
|
export interface TestQueryRowProps {
|
||||||
fetch: () => Promise<{
|
fetch: () => Promise<{
|
||||||
|
@ -27,14 +29,24 @@ export interface TestQueryRowProps {
|
||||||
}>;
|
}>;
|
||||||
copyQuery?: () => string;
|
copyQuery?: () => string;
|
||||||
hasValidationErrors: boolean;
|
hasValidationErrors: boolean;
|
||||||
|
showTable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TestQueryRow: React.FC<TestQueryRowProps> = ({
|
export const TestQueryRow: React.FC<TestQueryRowProps> = ({
|
||||||
fetch,
|
fetch,
|
||||||
copyQuery,
|
copyQuery,
|
||||||
hasValidationErrors,
|
hasValidationErrors,
|
||||||
|
showTable,
|
||||||
}) => {
|
}) => {
|
||||||
const { onTestQuery, testQueryResult, testQueryError, testQueryLoading } = useTestQuery(fetch);
|
const {
|
||||||
|
onTestQuery,
|
||||||
|
testQueryResult,
|
||||||
|
testQueryError,
|
||||||
|
testQueryLoading,
|
||||||
|
testQueryRawResults,
|
||||||
|
testQueryAlerts,
|
||||||
|
} = useTestQuery(fetch);
|
||||||
|
|
||||||
const [copiedMessage, setCopiedMessage] = useState<ReactNode | null>(null);
|
const [copiedMessage, setCopiedMessage] = useState<ReactNode | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -124,6 +136,12 @@ export const TestQueryRow: React.FC<TestQueryRowProps> = ({
|
||||||
</EuiText>
|
</EuiText>
|
||||||
</EuiFormRow>
|
</EuiFormRow>
|
||||||
)}
|
)}
|
||||||
|
{showTable && testQueryRawResults && (
|
||||||
|
<>
|
||||||
|
<EuiSpacer size="s" />
|
||||||
|
<TestQueryRowTable rawResults={testQueryRawResults} alerts={testQueryAlerts} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* 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 } from '@testing-library/react';
|
||||||
|
import { I18nProvider } from '@kbn/i18n-react';
|
||||||
|
import { TestQueryRowTable } from './test_query_row_table';
|
||||||
|
|
||||||
|
const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => (
|
||||||
|
<I18nProvider>{children}</I18nProvider>
|
||||||
|
));
|
||||||
|
|
||||||
|
describe('TestQueryRow', () => {
|
||||||
|
it('should render the datagrid', () => {
|
||||||
|
const result = render(
|
||||||
|
<TestQueryRowTable
|
||||||
|
rawResults={{
|
||||||
|
cols: [
|
||||||
|
{
|
||||||
|
id: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
test: 'esql query 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: 'esql query 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
alerts={null}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument();
|
||||||
|
expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2);
|
||||||
|
expect(result.queryByText('Alerts generated')).not.toBeInTheDocument();
|
||||||
|
expect(result.queryAllByTestId('alert-badge')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the datagrid and alerts if provided', () => {
|
||||||
|
const result = render(
|
||||||
|
<TestQueryRowTable
|
||||||
|
rawResults={{
|
||||||
|
cols: [
|
||||||
|
{
|
||||||
|
id: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
test: 'esql query 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: 'esql query 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
alerts={['alert1', 'alert2']}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: AppWrapper,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument();
|
||||||
|
expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2);
|
||||||
|
expect(result.getByText('Alerts generated')).toBeInTheDocument();
|
||||||
|
expect(result.getAllByTestId('alert-badge')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* 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 { css } from '@emotion/react';
|
||||||
|
import {
|
||||||
|
EuiDataGrid,
|
||||||
|
EuiPanel,
|
||||||
|
EuiDataGridColumn,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiText,
|
||||||
|
EuiBadge,
|
||||||
|
EuiSpacer,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
grid: css`
|
||||||
|
.euiDataGridHeaderCell {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.euiDataGridHeader .euiDataGridHeaderCell {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TestQueryRowTableProps {
|
||||||
|
rawResults: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> };
|
||||||
|
alerts: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestQueryRowTable: React.FC<TestQueryRowTableProps> = ({ rawResults, alerts }) => {
|
||||||
|
return (
|
||||||
|
<EuiPanel style={{ overflow: 'hidden' }} hasShadow={false} hasBorder={true}>
|
||||||
|
<EuiDataGrid
|
||||||
|
css={styles.grid}
|
||||||
|
aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.testQueryTableAriaLabel', {
|
||||||
|
defaultMessage: 'Test query grid',
|
||||||
|
})}
|
||||||
|
data-test-subj="test-query-row-datagrid"
|
||||||
|
columns={rawResults.cols}
|
||||||
|
columnVisibility={{
|
||||||
|
visibleColumns: rawResults.cols.map((c) => c.id),
|
||||||
|
setVisibleColumns: () => {},
|
||||||
|
}}
|
||||||
|
rowCount={rawResults.rows.length}
|
||||||
|
gridStyle={{
|
||||||
|
border: 'horizontal',
|
||||||
|
rowHover: 'none',
|
||||||
|
}}
|
||||||
|
renderCellValue={({ rowIndex, columnId }) => rawResults.rows[rowIndex][columnId]}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
onChangeItemsPerPage: () => {},
|
||||||
|
onChangePage: () => {},
|
||||||
|
}}
|
||||||
|
toolbarVisibility={false}
|
||||||
|
/>
|
||||||
|
<EuiSpacer size="m" />
|
||||||
|
{alerts && (
|
||||||
|
<EuiFlexGroup gutterSize="m">
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiText>
|
||||||
|
<h5>
|
||||||
|
{i18n.translate('xpack.stackAlerts.esQuery.ui.testQueryAlerts', {
|
||||||
|
defaultMessage: 'Alerts generated',
|
||||||
|
})}
|
||||||
|
</h5>
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{alerts.map((alert, index) => {
|
||||||
|
return (
|
||||||
|
<EuiFlexItem key={index} grow={false}>
|
||||||
|
<EuiBadge data-test-subj="alert-badge" color="primary">
|
||||||
|
{alert}
|
||||||
|
</EuiBadge>
|
||||||
|
</EuiFlexItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</EuiFlexGroup>
|
||||||
|
)}
|
||||||
|
</EuiPanel>
|
||||||
|
);
|
||||||
|
};
|
|
@ -20,6 +20,10 @@ describe('useTestQuery', () => {
|
||||||
},
|
},
|
||||||
isGrouped: false,
|
isGrouped: false,
|
||||||
timeWindow: '1s',
|
timeWindow: '1s',
|
||||||
|
rawResults: {
|
||||||
|
cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }],
|
||||||
|
rows: [{ ungrouped: 'test' }],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -29,6 +33,11 @@ describe('useTestQuery', () => {
|
||||||
expect(result.current.testQueryError).toBe(null);
|
expect(result.current.testQueryError).toBe(null);
|
||||||
expect(result.current.testQueryResult).toContain('1s');
|
expect(result.current.testQueryResult).toContain('1s');
|
||||||
expect(result.current.testQueryResult).toContain('1 document');
|
expect(result.current.testQueryResult).toContain('1 document');
|
||||||
|
expect(result.current.testQueryRawResults).toEqual({
|
||||||
|
cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }],
|
||||||
|
rows: [{ ungrouped: 'test' }],
|
||||||
|
});
|
||||||
|
expect(result.current.testQueryAlerts).toEqual(['query matched']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returning a valid result for grouped result', async () => {
|
test('returning a valid result for grouped result', async () => {
|
||||||
|
@ -44,6 +53,10 @@ describe('useTestQuery', () => {
|
||||||
},
|
},
|
||||||
isGrouped: true,
|
isGrouped: true,
|
||||||
timeWindow: '1s',
|
timeWindow: '1s',
|
||||||
|
rawResults: {
|
||||||
|
cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }],
|
||||||
|
rows: [{ grouped: 'test' }],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -55,6 +68,11 @@ describe('useTestQuery', () => {
|
||||||
expect(result.current.testQueryResult).toContain(
|
expect(result.current.testQueryResult).toContain(
|
||||||
'Grouped query matched 2 groups in the last 1s.'
|
'Grouped query matched 2 groups in the last 1s.'
|
||||||
);
|
);
|
||||||
|
expect(result.current.testQueryRawResults).toEqual({
|
||||||
|
cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }],
|
||||||
|
rows: [{ grouped: 'test' }],
|
||||||
|
});
|
||||||
|
expect(result.current.testQueryAlerts).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returning an error', async () => {
|
test('returning an error', async () => {
|
||||||
|
@ -68,5 +86,7 @@ describe('useTestQuery', () => {
|
||||||
expect(result.current.testQueryLoading).toBe(false);
|
expect(result.current.testQueryLoading).toBe(false);
|
||||||
expect(result.current.testQueryError).toContain(errorMsg);
|
expect(result.current.testQueryError).toContain(errorMsg);
|
||||||
expect(result.current.testQueryResult).toBe(null);
|
expect(result.current.testQueryResult).toBe(null);
|
||||||
|
expect(result.current.testQueryRawResults).toBe(null);
|
||||||
|
expect(result.current.testQueryAlerts).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,17 +8,22 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import type { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
|
import type { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
|
||||||
|
import { EuiDataGridColumn } from '@elastic/eui';
|
||||||
|
|
||||||
interface TestQueryResponse {
|
interface TestQueryResponse {
|
||||||
result: string | null;
|
result: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
rawResults: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> } | null;
|
||||||
|
alerts: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEST_QUERY_INITIAL_RESPONSE: TestQueryResponse = {
|
const TEST_QUERY_INITIAL_RESPONSE: TestQueryResponse = {
|
||||||
result: null,
|
result: null,
|
||||||
error: null,
|
error: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
rawResults: null,
|
||||||
|
alerts: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -30,6 +35,7 @@ export function useTestQuery(
|
||||||
testResults: ParsedAggregationResults;
|
testResults: ParsedAggregationResults;
|
||||||
isGrouped: boolean;
|
isGrouped: boolean;
|
||||||
timeWindow: string;
|
timeWindow: string;
|
||||||
|
rawResults?: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> };
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const [testQueryResponse, setTestQueryResponse] = useState<TestQueryResponse>(
|
const [testQueryResponse, setTestQueryResponse] = useState<TestQueryResponse>(
|
||||||
|
@ -46,10 +52,12 @@ export function useTestQuery(
|
||||||
result: null,
|
result: null,
|
||||||
error: null,
|
error: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
rawResults: null,
|
||||||
|
alerts: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { testResults, isGrouped, timeWindow } = await fetch();
|
const { testResults, isGrouped, timeWindow, rawResults } = await fetch();
|
||||||
|
|
||||||
if (isGrouped) {
|
if (isGrouped) {
|
||||||
setTestQueryResponse({
|
setTestQueryResponse({
|
||||||
|
@ -62,6 +70,11 @@ export function useTestQuery(
|
||||||
}),
|
}),
|
||||||
error: null,
|
error: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
rawResults: rawResults ?? null,
|
||||||
|
alerts:
|
||||||
|
testResults.results.length > 0
|
||||||
|
? testResults.results.map((result) => result.group)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const ungroupedQueryResponse =
|
const ungroupedQueryResponse =
|
||||||
|
@ -73,6 +86,8 @@ export function useTestQuery(
|
||||||
}),
|
}),
|
||||||
error: null,
|
error: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
rawResults: rawResults ?? null,
|
||||||
|
alerts: ungroupedQueryResponse.count > 0 ? ['query matched'] : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -85,6 +100,8 @@ export function useTestQuery(
|
||||||
values: { message: message ? `${err.message}: ${message}` : err.message },
|
values: { message: message ? `${err.message}: ${message}` : err.message },
|
||||||
}),
|
}),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
rawResults: null,
|
||||||
|
alerts: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [fetch]);
|
}, [fetch]);
|
||||||
|
@ -94,5 +111,7 @@ export function useTestQuery(
|
||||||
testQueryResult: testQueryResponse.result,
|
testQueryResult: testQueryResponse.result,
|
||||||
testQueryError: testQueryResponse.error,
|
testQueryError: testQueryResponse.error,
|
||||||
testQueryLoading: testQueryResponse.isLoading,
|
testQueryLoading: testQueryResponse.isLoading,
|
||||||
|
testQueryRawResults: testQueryResponse.rawResults,
|
||||||
|
testQueryAlerts: testQueryResponse.alerts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,12 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common';
|
||||||
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
||||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||||
|
import { AggregateQuery } from '@kbn/es-query';
|
||||||
|
|
||||||
export enum SearchType {
|
export enum SearchType {
|
||||||
esQuery = 'esQuery',
|
esQuery = 'esQuery',
|
||||||
searchSource = 'searchSource',
|
searchSource = 'searchSource',
|
||||||
|
esqlQuery = 'esqlQuery',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommonRuleParams {
|
export interface CommonRuleParams {
|
||||||
|
@ -38,6 +40,8 @@ export interface EsQueryRuleMetaData {
|
||||||
|
|
||||||
export type EsQueryRuleParams<T = SearchType> = T extends SearchType.searchSource
|
export type EsQueryRuleParams<T = SearchType> = T extends SearchType.searchSource
|
||||||
? CommonEsQueryRuleParams & OnlySearchSourceRuleParams
|
? CommonEsQueryRuleParams & OnlySearchSourceRuleParams
|
||||||
|
: T extends SearchType.esqlQuery
|
||||||
|
? CommonEsQueryRuleParams & OnlyEsqlQueryRuleParams
|
||||||
: CommonEsQueryRuleParams & OnlyEsQueryRuleParams;
|
: CommonEsQueryRuleParams & OnlyEsQueryRuleParams;
|
||||||
|
|
||||||
export interface OnlyEsQueryRuleParams {
|
export interface OnlyEsQueryRuleParams {
|
||||||
|
@ -53,4 +57,10 @@ export interface OnlySearchSourceRuleParams {
|
||||||
savedQueryId?: string;
|
savedQueryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnlyEsqlQueryRuleParams {
|
||||||
|
searchType?: 'esqlQuery';
|
||||||
|
esqlQuery: AggregateQuery;
|
||||||
|
timeField: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type DataViewOption = EuiComboBoxOptionOption<string>;
|
export type DataViewOption = EuiComboBoxOptionOption<string>;
|
||||||
|
|
|
@ -17,6 +17,12 @@ export const isSearchSourceRule = (
|
||||||
return ruleParams.searchType === 'searchSource';
|
return ruleParams.searchType === 'searchSource';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isEsqlQueryRule = (
|
||||||
|
ruleParams: EsQueryRuleParams
|
||||||
|
): ruleParams is EsQueryRuleParams<SearchType.esqlQuery> => {
|
||||||
|
return ruleParams.searchType === 'esqlQuery';
|
||||||
|
};
|
||||||
|
|
||||||
export const convertFieldSpecToFieldOption = (fieldSpec: FieldSpec[]): FieldOption[] => {
|
export const convertFieldSpecToFieldOption = (fieldSpec: FieldSpec[]): FieldOption[] => {
|
||||||
return (fieldSpec ?? [])
|
return (fieldSpec ?? [])
|
||||||
.filter((spec: FieldSpec) => spec.isMapped || spec.runtimeField)
|
.filter((spec: FieldSpec) => spec.isMapped || spec.runtimeField)
|
||||||
|
|
|
@ -278,4 +278,63 @@ describe('expression params validation', () => {
|
||||||
expect(validateExpression(initialParams).errors.size.length).toBe(0);
|
expect(validateExpression(initialParams).errors.size.length).toBe(0);
|
||||||
expect(hasExpressionValidationErrors(initialParams)).toBe(false);
|
expect(hasExpressionValidationErrors(initialParams)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('if esqlQuery property is not set should return proper error message', () => {
|
||||||
|
const initialParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 1,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
threshold: [0],
|
||||||
|
timeField: '@timestamp',
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
} as EsQueryRuleParams<SearchType.esqlQuery>;
|
||||||
|
expect(validateExpression(initialParams).errors.esqlQuery.length).toBeGreaterThan(0);
|
||||||
|
expect(validateExpression(initialParams).errors.esqlQuery[0]).toBe(`ESQL query is required.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if esqlQuery timeField property is not defined should return proper error message', () => {
|
||||||
|
const initialParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 1,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
threshold: [0],
|
||||||
|
esqlQuery: { esql: 'test' },
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
} as EsQueryRuleParams<SearchType.esqlQuery>;
|
||||||
|
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
|
||||||
|
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if esqlQuery thresholdComparator property is not gt should return proper error message', () => {
|
||||||
|
const initialParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 1,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
threshold: [0],
|
||||||
|
esqlQuery: { esql: 'test' },
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
thresholdComparator: '<',
|
||||||
|
timeField: '@timestamp',
|
||||||
|
} as EsQueryRuleParams<SearchType.esqlQuery>;
|
||||||
|
expect(validateExpression(initialParams).errors.thresholdComparator.length).toBeGreaterThan(0);
|
||||||
|
expect(validateExpression(initialParams).errors.thresholdComparator[0]).toBe(
|
||||||
|
'Threshold comparator is required to be greater than.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if esqlQuery threshold property is not 0 should return proper error message', () => {
|
||||||
|
const initialParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 1,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
threshold: [8],
|
||||||
|
esqlQuery: { esql: 'test' },
|
||||||
|
searchType: SearchType.esqlQuery,
|
||||||
|
timeField: '@timestamp',
|
||||||
|
} as EsQueryRuleParams<SearchType.esqlQuery>;
|
||||||
|
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
|
||||||
|
expect(validateExpression(initialParams).errors.threshold0[0]).toBe(
|
||||||
|
'Threshold is required to be 0.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,11 +12,13 @@ import {
|
||||||
builtInComparators,
|
builtInComparators,
|
||||||
builtInAggregationTypes,
|
builtInAggregationTypes,
|
||||||
builtInGroupByTypes,
|
builtInGroupByTypes,
|
||||||
|
COMPARATORS,
|
||||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||||
import { EsQueryRuleParams, SearchType } from './types';
|
import { EsQueryRuleParams, SearchType } from './types';
|
||||||
import { isSearchSourceRule } from './util';
|
import { isEsqlQueryRule, isSearchSourceRule } from './util';
|
||||||
import {
|
import {
|
||||||
COMMON_EXPRESSION_ERRORS,
|
COMMON_EXPRESSION_ERRORS,
|
||||||
|
ONLY_ESQL_QUERY_EXPRESSION_ERRORS,
|
||||||
ONLY_ES_QUERY_EXPRESSION_ERRORS,
|
ONLY_ES_QUERY_EXPRESSION_ERRORS,
|
||||||
SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,
|
SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
@ -221,6 +223,46 @@ const validateEsQueryParams = (ruleParams: EsQueryRuleParams<SearchType.esQuery>
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateEsqlQueryParams = (ruleParams: EsQueryRuleParams<SearchType.esqlQuery>) => {
|
||||||
|
const errors: typeof ONLY_ESQL_QUERY_EXPRESSION_ERRORS = defaultsDeep(
|
||||||
|
{},
|
||||||
|
ONLY_ESQL_QUERY_EXPRESSION_ERRORS
|
||||||
|
);
|
||||||
|
if (!ruleParams.esqlQuery) {
|
||||||
|
errors.esqlQuery.push(
|
||||||
|
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredQueryText', {
|
||||||
|
defaultMessage: 'ESQL query is required.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ruleParams.timeField) {
|
||||||
|
errors.timeField.push(
|
||||||
|
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredTimeFieldText', {
|
||||||
|
defaultMessage: 'Time field is required.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ruleParams.thresholdComparator !== COMPARATORS.GREATER_THAN) {
|
||||||
|
errors.thresholdComparator.push(
|
||||||
|
i18n.translate(
|
||||||
|
'xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThresholdComparatorText',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Threshold comparator is required to be greater than.',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ruleParams.threshold && ruleParams.threshold[0] !== 0) {
|
||||||
|
errors.threshold0.push(
|
||||||
|
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThreshold0Text', {
|
||||||
|
defaultMessage: 'Threshold is required to be 0.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationResult => {
|
export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationResult => {
|
||||||
const validationResult = { errors: {} };
|
const validationResult = { errors: {} };
|
||||||
|
|
||||||
|
@ -234,8 +276,7 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes
|
||||||
* It's important to report searchSource rule related errors only into errors.searchConfiguration prop.
|
* It's important to report searchSource rule related errors only into errors.searchConfiguration prop.
|
||||||
* For example errors.index is a mistake to report searchSource rule related errors. It will lead to issues.
|
* For example errors.index is a mistake to report searchSource rule related errors. It will lead to issues.
|
||||||
*/
|
*/
|
||||||
const isSearchSource = isSearchSourceRule(ruleParams);
|
if (isSearchSourceRule(ruleParams)) {
|
||||||
if (isSearchSource) {
|
|
||||||
validationResult.errors = {
|
validationResult.errors = {
|
||||||
...validationResult.errors,
|
...validationResult.errors,
|
||||||
...validateSearchSourceParams(ruleParams),
|
...validateSearchSourceParams(ruleParams),
|
||||||
|
@ -243,6 +284,14 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes
|
||||||
return validationResult;
|
return validationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEsqlQueryRule(ruleParams)) {
|
||||||
|
validationResult.errors = {
|
||||||
|
...validationResult.errors,
|
||||||
|
...validateEsqlQueryParams(ruleParams),
|
||||||
|
};
|
||||||
|
return validationResult;
|
||||||
|
}
|
||||||
|
|
||||||
const esQueryErrors = validateEsQueryParams(ruleParams as EsQueryRuleParams<SearchType.esQuery>);
|
const esQueryErrors = validateEsQueryParams(ruleParams as EsQueryRuleParams<SearchType.esQuery>);
|
||||||
validationResult.errors = { ...validationResult.errors, ...esQueryErrors };
|
validationResult.errors = { ...validationResult.errors, ...esQueryErrors };
|
||||||
return validationResult;
|
return validationResult;
|
||||||
|
|
|
@ -167,6 +167,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
comparator: Comparator.GT,
|
comparator: Comparator.GT,
|
||||||
threshold: [10],
|
threshold: [10],
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(`Number of matching documents is greater than 10`);
|
expect(result).toBe(`Number of matching documents is greater than 10`);
|
||||||
});
|
});
|
||||||
|
@ -177,6 +178,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
threshold: [10],
|
threshold: [10],
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
isRecovered: true,
|
isRecovered: true,
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(`Number of matching documents is NOT greater than 10`);
|
expect(result).toBe(`Number of matching documents is NOT greater than 10`);
|
||||||
});
|
});
|
||||||
|
@ -187,6 +189,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
threshold: [10, 20],
|
threshold: [10, 20],
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
isRecovered: true,
|
isRecovered: true,
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(`Number of matching documents is NOT between 10 and 20`);
|
expect(result).toBe(`Number of matching documents is NOT between 10 and 20`);
|
||||||
});
|
});
|
||||||
|
@ -197,6 +200,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
threshold: [10],
|
threshold: [10],
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
group: 'host-1',
|
group: 'host-1',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(`Number of matching documents for group "host-1" is greater than 10`);
|
expect(result).toBe(`Number of matching documents for group "host-1" is greater than 10`);
|
||||||
});
|
});
|
||||||
|
@ -208,6 +212,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
isRecovered: true,
|
isRecovered: true,
|
||||||
group: 'host-1',
|
group: 'host-1',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(`Number of matching documents for group "host-1" is NOT greater than 10`);
|
expect(result).toBe(`Number of matching documents for group "host-1" is NOT greater than 10`);
|
||||||
});
|
});
|
||||||
|
@ -218,6 +223,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
threshold: [10],
|
threshold: [10],
|
||||||
aggType: 'min',
|
aggType: 'min',
|
||||||
aggField: 'numericField',
|
aggField: 'numericField',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(
|
expect(result).toBe(
|
||||||
`Number of matching documents where min of numericField is greater than 10`
|
`Number of matching documents where min of numericField is greater than 10`
|
||||||
|
@ -231,6 +237,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
aggType: 'min',
|
aggType: 'min',
|
||||||
aggField: 'numericField',
|
aggField: 'numericField',
|
||||||
isRecovered: true,
|
isRecovered: true,
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(
|
expect(result).toBe(
|
||||||
`Number of matching documents where min of numericField is NOT greater than 10`
|
`Number of matching documents where min of numericField is NOT greater than 10`
|
||||||
|
@ -244,6 +251,7 @@ describe('getContextConditionsDescription', () => {
|
||||||
group: 'host-1',
|
group: 'host-1',
|
||||||
aggType: 'max',
|
aggType: 'max',
|
||||||
aggField: 'numericField',
|
aggField: 'numericField',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(
|
expect(result).toBe(
|
||||||
`Number of matching documents for group "host-1" where max of numericField is greater than 10`
|
`Number of matching documents for group "host-1" where max of numericField is greater than 10`
|
||||||
|
@ -258,9 +266,31 @@ describe('getContextConditionsDescription', () => {
|
||||||
group: 'host-1',
|
group: 'host-1',
|
||||||
aggType: 'max',
|
aggType: 'max',
|
||||||
aggField: 'numericField',
|
aggField: 'numericField',
|
||||||
|
searchType: 'esQuery',
|
||||||
});
|
});
|
||||||
expect(result).toBe(
|
expect(result).toBe(
|
||||||
`Number of matching documents for group "host-1" where max of numericField is NOT greater than 10`
|
`Number of matching documents for group "host-1" where max of numericField is NOT greater than 10`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return conditions correctly for ESQL search type', () => {
|
||||||
|
const result = getContextConditionsDescription({
|
||||||
|
comparator: Comparator.GT,
|
||||||
|
threshold: [0],
|
||||||
|
aggType: 'count',
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
});
|
||||||
|
expect(result).toBe(`Query matched documents`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return conditions correctly ESQL search type when isRecovered is true', () => {
|
||||||
|
const result = getContextConditionsDescription({
|
||||||
|
comparator: Comparator.GT,
|
||||||
|
threshold: [0],
|
||||||
|
aggType: 'count',
|
||||||
|
isRecovered: true,
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
});
|
||||||
|
expect(result).toBe(`Query did NOT match documents`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { AlertInstanceContext } from '@kbn/alerting-plugin/server';
|
||||||
import { EsQueryRuleParams } from './rule_type_params';
|
import { EsQueryRuleParams } from './rule_type_params';
|
||||||
import { Comparator } from '../../../common/comparator_types';
|
import { Comparator } from '../../../common/comparator_types';
|
||||||
import { getHumanReadableComparator } from '../../../common';
|
import { getHumanReadableComparator } from '../../../common';
|
||||||
|
import { isEsqlQueryRule } from './util';
|
||||||
|
|
||||||
// rule type context provided to actions
|
// rule type context provided to actions
|
||||||
export interface ActionContext extends EsQueryRuleActionContext {
|
export interface ActionContext extends EsQueryRuleActionContext {
|
||||||
|
@ -79,6 +80,7 @@ export function addMessages({
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetContextConditionsDescriptionOpts {
|
interface GetContextConditionsDescriptionOpts {
|
||||||
|
searchType: 'searchSource' | 'esQuery' | 'esqlQuery';
|
||||||
comparator: Comparator;
|
comparator: Comparator;
|
||||||
threshold: number[];
|
threshold: number[];
|
||||||
aggType: string;
|
aggType: string;
|
||||||
|
@ -88,6 +90,7 @@ interface GetContextConditionsDescriptionOpts {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContextConditionsDescription({
|
export function getContextConditionsDescription({
|
||||||
|
searchType,
|
||||||
comparator,
|
comparator,
|
||||||
threshold,
|
threshold,
|
||||||
aggType,
|
aggType,
|
||||||
|
@ -95,15 +98,23 @@ export function getContextConditionsDescription({
|
||||||
isRecovered = false,
|
isRecovered = false,
|
||||||
group,
|
group,
|
||||||
}: GetContextConditionsDescriptionOpts) {
|
}: GetContextConditionsDescriptionOpts) {
|
||||||
return i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', {
|
return isEsqlQueryRule(searchType)
|
||||||
defaultMessage:
|
? i18n.translate('xpack.stackAlerts.esQuery.esqlAlertTypeContextConditionsDescription', {
|
||||||
'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}',
|
defaultMessage: 'Query{negation} documents{groupCondition}',
|
||||||
values: {
|
values: {
|
||||||
aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`,
|
groupCondition: group ? ` for group "${group}"` : '',
|
||||||
groupCondition: group ? ` for group "${group}"` : '',
|
negation: isRecovered ? ' did NOT match' : ' matched',
|
||||||
thresholdComparator: getHumanReadableComparator(comparator),
|
},
|
||||||
threshold: threshold.join(' and '),
|
})
|
||||||
negation: isRecovered ? 'NOT ' : '',
|
: i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', {
|
||||||
},
|
defaultMessage:
|
||||||
});
|
'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}',
|
||||||
|
values: {
|
||||||
|
aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`,
|
||||||
|
groupCondition: group ? ` for group "${group}"` : '',
|
||||||
|
thresholdComparator: getHumanReadableComparator(comparator),
|
||||||
|
threshold: threshold.join(' and '),
|
||||||
|
negation: isRecovered ? 'NOT ' : '',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { ISearchStartSearchSource } from '@kbn/data-plugin/common';
|
||||||
import { EsQueryRuleParams } from './rule_type_params';
|
import { EsQueryRuleParams } from './rule_type_params';
|
||||||
import { FetchEsQueryOpts } from './lib/fetch_es_query';
|
import { FetchEsQueryOpts } from './lib/fetch_es_query';
|
||||||
import { FetchSearchSourceQueryOpts } from './lib/fetch_search_source_query';
|
import { FetchSearchSourceQueryOpts } from './lib/fetch_search_source_query';
|
||||||
|
import { FetchEsqlQueryOpts } from './lib/fetch_esql_query';
|
||||||
|
|
||||||
const logger = loggerMock.create();
|
const logger = loggerMock.create();
|
||||||
const scopedClusterClientMock = elasticsearchServiceMock.createScopedClusterClient();
|
const scopedClusterClientMock = elasticsearchServiceMock.createScopedClusterClient();
|
||||||
|
@ -44,6 +45,10 @@ jest.mock('./lib/fetch_search_source_query', () => ({
|
||||||
fetchSearchSourceQuery: (...args: [FetchSearchSourceQueryOpts]) =>
|
fetchSearchSourceQuery: (...args: [FetchSearchSourceQueryOpts]) =>
|
||||||
mockFetchSearchSourceQuery(...args),
|
mockFetchSearchSourceQuery(...args),
|
||||||
}));
|
}));
|
||||||
|
const mockFetchEsqlQuery = jest.fn();
|
||||||
|
jest.mock('./lib/fetch_esql_query', () => ({
|
||||||
|
fetchEsqlQuery: (...args: [FetchEsqlQueryOpts]) => mockFetchEsqlQuery(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]);
|
const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]);
|
||||||
const mockSetLimitReached = jest.fn();
|
const mockSetLimitReached = jest.fn();
|
||||||
|
@ -86,6 +91,8 @@ describe('es_query executor', () => {
|
||||||
excludeHitsFromPreviousRun: true,
|
excludeHitsFromPreviousRun: true,
|
||||||
aggType: 'count',
|
aggType: 'count',
|
||||||
groupBy: 'all',
|
groupBy: 'all',
|
||||||
|
searchConfiguration: {},
|
||||||
|
esqlQuery: { esql: 'test-query' },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('executor', () => {
|
describe('executor', () => {
|
||||||
|
@ -171,12 +178,12 @@ describe('es_query executor', () => {
|
||||||
});
|
});
|
||||||
await executor(coreMock, {
|
await executor(coreMock, {
|
||||||
...defaultExecutorOptions,
|
...defaultExecutorOptions,
|
||||||
params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' },
|
params: { ...defaultProps, searchType: 'searchSource' },
|
||||||
});
|
});
|
||||||
expect(mockFetchSearchSourceQuery).toHaveBeenCalledWith({
|
expect(mockFetchSearchSourceQuery).toHaveBeenCalledWith({
|
||||||
ruleId: 'test-rule-id',
|
ruleId: 'test-rule-id',
|
||||||
alertLimit: 1000,
|
alertLimit: 1000,
|
||||||
params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' },
|
params: { ...defaultProps, searchType: 'searchSource' },
|
||||||
latestTimestamp: undefined,
|
latestTimestamp: undefined,
|
||||||
services: {
|
services: {
|
||||||
searchSourceClient: searchSourceClientMock,
|
searchSourceClient: searchSourceClientMock,
|
||||||
|
@ -188,6 +195,42 @@ describe('es_query executor', () => {
|
||||||
expect(mockFetchEsQuery).not.toHaveBeenCalled();
|
expect(mockFetchEsQuery).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call fetchEsqlQuery if searchType is esqlQuery', async () => {
|
||||||
|
mockFetchEsqlQuery.mockResolvedValueOnce({
|
||||||
|
parsedResults: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
group: 'all documents',
|
||||||
|
count: 491,
|
||||||
|
hits: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
dateStart: new Date().toISOString(),
|
||||||
|
dateEnd: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await executor(coreMock, {
|
||||||
|
...defaultExecutorOptions,
|
||||||
|
params: { ...defaultProps, searchType: 'esqlQuery' },
|
||||||
|
});
|
||||||
|
expect(mockFetchEsqlQuery).toHaveBeenCalledWith({
|
||||||
|
ruleId: 'test-rule-id',
|
||||||
|
alertLimit: 1000,
|
||||||
|
params: { ...defaultProps, searchType: 'esqlQuery' },
|
||||||
|
services: {
|
||||||
|
scopedClusterClient: scopedClusterClientMock,
|
||||||
|
logger,
|
||||||
|
share: undefined,
|
||||||
|
dataViews: undefined,
|
||||||
|
},
|
||||||
|
spacePrefix: '',
|
||||||
|
publicBaseUrl: 'https://localhost:5601',
|
||||||
|
});
|
||||||
|
expect(mockFetchEsQuery).not.toHaveBeenCalled();
|
||||||
|
expect(mockFetchSearchSourceQuery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not create alert if compare function returns false for ungrouped alert', async () => {
|
it('should not create alert if compare function returns false for ungrouped alert', async () => {
|
||||||
mockFetchEsQuery.mockResolvedValueOnce({
|
mockFetchEsQuery.mockResolvedValueOnce({
|
||||||
parsedResults: {
|
parsedResults: {
|
||||||
|
@ -455,6 +498,78 @@ describe('es_query executor', () => {
|
||||||
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
|
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create alert if there are hits for ESQL alert', async () => {
|
||||||
|
mockFetchEsqlQuery.mockResolvedValueOnce({
|
||||||
|
parsedResults: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
group: 'all documents',
|
||||||
|
count: 198,
|
||||||
|
hits: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
dateStart: new Date().toISOString(),
|
||||||
|
dateEnd: new Date().toISOString(),
|
||||||
|
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
});
|
||||||
|
await executor(coreMock, {
|
||||||
|
...defaultExecutorOptions,
|
||||||
|
params: {
|
||||||
|
...defaultProps,
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
threshold: [0],
|
||||||
|
thresholdComparator: '>=' as Comparator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReport).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockReport).toHaveBeenNthCalledWith(1, {
|
||||||
|
actionGroup: 'query matched',
|
||||||
|
context: {
|
||||||
|
conditions: 'Query matched documents',
|
||||||
|
date: new Date(mockNow).toISOString(),
|
||||||
|
hits: [],
|
||||||
|
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
message: `rule 'test-rule-name' is active:
|
||||||
|
|
||||||
|
- Value: 198
|
||||||
|
- Conditions Met: Query matched documents over 5m
|
||||||
|
- Timestamp: ${new Date(mockNow).toISOString()}
|
||||||
|
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
||||||
|
title: "rule 'test-rule-name' matched query",
|
||||||
|
value: 198,
|
||||||
|
},
|
||||||
|
id: 'query matched',
|
||||||
|
payload: {
|
||||||
|
kibana: {
|
||||||
|
alert: {
|
||||||
|
evaluation: {
|
||||||
|
conditions: 'Query matched documents',
|
||||||
|
value: 198,
|
||||||
|
},
|
||||||
|
reason: `rule 'test-rule-name' is active:
|
||||||
|
|
||||||
|
- Value: 198
|
||||||
|
- Conditions Met: Query matched documents over 5m
|
||||||
|
- Timestamp: ${new Date(mockNow).toISOString()}
|
||||||
|
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
||||||
|
title: "rule 'test-rule-name' matched query",
|
||||||
|
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
dateEnd: new Date(mockNow).toISOString(),
|
||||||
|
dateStart: new Date(mockNow).toISOString(),
|
||||||
|
latestTimestamp: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockSetLimitReached).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set limit as reached if results are truncated', async () => {
|
it('should set limit as reached if results are truncated', async () => {
|
||||||
mockFetchEsQuery.mockResolvedValueOnce({
|
mockFetchEsQuery.mockResolvedValueOnce({
|
||||||
parsedResults: {
|
parsedResults: {
|
||||||
|
@ -670,6 +785,74 @@ describe('es_query executor', () => {
|
||||||
- Value: 0
|
- Value: 0
|
||||||
- Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m
|
- Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m
|
||||||
- Timestamp: ${new Date(mockNow).toISOString()}
|
- Timestamp: ${new Date(mockNow).toISOString()}
|
||||||
|
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
||||||
|
title: "rule 'test-rule-name' recovered",
|
||||||
|
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockSetLimitReached).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly handle recovered alerts for ESQL alert', async () => {
|
||||||
|
mockGetRecoveredAlerts.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
alert: {
|
||||||
|
getId: () => 'query matched',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockFetchEsqlQuery.mockResolvedValueOnce({
|
||||||
|
parsedResults: {
|
||||||
|
results: [],
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
dateStart: new Date().toISOString(),
|
||||||
|
dateEnd: new Date().toISOString(),
|
||||||
|
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
});
|
||||||
|
await executor(coreMock, {
|
||||||
|
...defaultExecutorOptions,
|
||||||
|
params: {
|
||||||
|
...defaultProps,
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
threshold: [0],
|
||||||
|
thresholdComparator: '>=' as Comparator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReport).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetAlertData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetAlertData).toHaveBeenNthCalledWith(1, {
|
||||||
|
id: 'query matched',
|
||||||
|
context: {
|
||||||
|
conditions: 'Query did NOT match documents',
|
||||||
|
date: new Date(mockNow).toISOString(),
|
||||||
|
hits: [],
|
||||||
|
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
message: `rule 'test-rule-name' is recovered:
|
||||||
|
|
||||||
|
- Value: 0
|
||||||
|
- Conditions Met: Query did NOT match documents over 5m
|
||||||
|
- Timestamp: ${new Date(mockNow).toISOString()}
|
||||||
|
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
||||||
|
title: "rule 'test-rule-name' recovered",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
kibana: {
|
||||||
|
alert: {
|
||||||
|
evaluation: {
|
||||||
|
conditions: 'Query did NOT match documents',
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
reason: `rule 'test-rule-name' is recovered:
|
||||||
|
|
||||||
|
- Value: 0
|
||||||
|
- Conditions Met: Query did NOT match documents over 5m
|
||||||
|
- Timestamp: ${new Date(mockNow).toISOString()}
|
||||||
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
|
||||||
title: "rule 'test-rule-name' recovered",
|
title: "rule 'test-rule-name' recovered",
|
||||||
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
|
||||||
|
|
|
@ -19,15 +19,22 @@ import {
|
||||||
EsQueryRuleActionContext,
|
EsQueryRuleActionContext,
|
||||||
getContextConditionsDescription,
|
getContextConditionsDescription,
|
||||||
} from './action_context';
|
} from './action_context';
|
||||||
import { ExecutorOptions, OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types';
|
import {
|
||||||
|
ExecutorOptions,
|
||||||
|
OnlyEsQueryRuleParams,
|
||||||
|
OnlySearchSourceRuleParams,
|
||||||
|
OnlyEsqlQueryRuleParams,
|
||||||
|
} from './types';
|
||||||
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
|
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
|
||||||
import { fetchEsQuery } from './lib/fetch_es_query';
|
import { fetchEsQuery } from './lib/fetch_es_query';
|
||||||
import { EsQueryRuleParams } from './rule_type_params';
|
import { EsQueryRuleParams } from './rule_type_params';
|
||||||
import { fetchSearchSourceQuery } from './lib/fetch_search_source_query';
|
import { fetchSearchSourceQuery } from './lib/fetch_search_source_query';
|
||||||
import { isEsQueryRule } from './util';
|
import { isEsqlQueryRule, isSearchSourceRule } from './util';
|
||||||
|
import { fetchEsqlQuery } from './lib/fetch_esql_query';
|
||||||
|
|
||||||
export async function executor(core: CoreSetup, options: ExecutorOptions<EsQueryRuleParams>) {
|
export async function executor(core: CoreSetup, options: ExecutorOptions<EsQueryRuleParams>) {
|
||||||
const esQueryRule = isEsQueryRule(options.params.searchType);
|
const searchSourceRule = isSearchSourceRule(options.params.searchType);
|
||||||
|
const esqlQueryRule = isEsqlQueryRule(options.params.searchType);
|
||||||
const {
|
const {
|
||||||
rule: { id: ruleId, name },
|
rule: { id: ruleId, name },
|
||||||
services,
|
services,
|
||||||
|
@ -54,21 +61,8 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
|
||||||
// avoid counting a document multiple times.
|
// avoid counting a document multiple times.
|
||||||
// latestTimestamp will be ignored if set for grouped queries
|
// latestTimestamp will be ignored if set for grouped queries
|
||||||
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
|
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
|
||||||
const { parsedResults, dateStart, dateEnd, link } = esQueryRule
|
const { parsedResults, dateStart, dateEnd, link } = searchSourceRule
|
||||||
? await fetchEsQuery({
|
? await fetchSearchSourceQuery({
|
||||||
ruleId,
|
|
||||||
name,
|
|
||||||
alertLimit,
|
|
||||||
params: params as OnlyEsQueryRuleParams,
|
|
||||||
timestamp: latestTimestamp,
|
|
||||||
publicBaseUrl,
|
|
||||||
spacePrefix,
|
|
||||||
services: {
|
|
||||||
scopedClusterClient,
|
|
||||||
logger,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await fetchSearchSourceQuery({
|
|
||||||
ruleId,
|
ruleId,
|
||||||
alertLimit,
|
alertLimit,
|
||||||
params: params as OnlySearchSourceRuleParams,
|
params: params as OnlySearchSourceRuleParams,
|
||||||
|
@ -80,8 +74,34 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
|
||||||
logger,
|
logger,
|
||||||
dataViews,
|
dataViews,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
: esqlQueryRule
|
||||||
|
? await fetchEsqlQuery({
|
||||||
|
ruleId,
|
||||||
|
alertLimit,
|
||||||
|
params: params as OnlyEsqlQueryRuleParams,
|
||||||
|
spacePrefix,
|
||||||
|
publicBaseUrl,
|
||||||
|
services: {
|
||||||
|
share,
|
||||||
|
scopedClusterClient,
|
||||||
|
logger,
|
||||||
|
dataViews,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await fetchEsQuery({
|
||||||
|
ruleId,
|
||||||
|
name,
|
||||||
|
alertLimit,
|
||||||
|
params: params as OnlyEsQueryRuleParams,
|
||||||
|
timestamp: latestTimestamp,
|
||||||
|
publicBaseUrl,
|
||||||
|
spacePrefix,
|
||||||
|
services: {
|
||||||
|
scopedClusterClient,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const unmetGroupValues: Record<string, number> = {};
|
const unmetGroupValues: Record<string, number> = {};
|
||||||
for (const result of parsedResults.results) {
|
for (const result of parsedResults.results) {
|
||||||
const alertId = result.group;
|
const alertId = result.group;
|
||||||
|
@ -105,6 +125,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
|
||||||
const baseActiveContext: EsQueryRuleActionContext = {
|
const baseActiveContext: EsQueryRuleActionContext = {
|
||||||
...baseContext,
|
...baseContext,
|
||||||
conditions: getContextConditionsDescription({
|
conditions: getContextConditionsDescription({
|
||||||
|
searchType: params.searchType,
|
||||||
comparator: params.thresholdComparator,
|
comparator: params.thresholdComparator,
|
||||||
threshold: params.threshold,
|
threshold: params.threshold,
|
||||||
aggType: params.aggType,
|
aggType: params.aggType,
|
||||||
|
@ -157,6 +178,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
|
||||||
hits: [],
|
hits: [],
|
||||||
link,
|
link,
|
||||||
conditions: getContextConditionsDescription({
|
conditions: getContextConditionsDescription({
|
||||||
|
searchType: params.searchType,
|
||||||
comparator: params.thresholdComparator,
|
comparator: params.thresholdComparator,
|
||||||
threshold: params.threshold,
|
threshold: params.threshold,
|
||||||
isRecovered: true,
|
isRecovered: true,
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* 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 { OnlyEsqlQueryRuleParams } from '../types';
|
||||||
|
import { stubbedSavedObjectIndexPattern } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||||
|
import { DataView } from '@kbn/data-views-plugin/common';
|
||||||
|
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
|
||||||
|
import { Comparator } from '../../../../common/comparator_types';
|
||||||
|
import { getEsqlQuery } from './fetch_esql_query';
|
||||||
|
|
||||||
|
const createDataView = () => {
|
||||||
|
const id = 'test-id';
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
version,
|
||||||
|
attributes: { timeFieldName, fields, title },
|
||||||
|
} = stubbedSavedObjectIndexPattern(id);
|
||||||
|
|
||||||
|
return new DataView({
|
||||||
|
spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title },
|
||||||
|
fieldFormats: fieldFormatsMock,
|
||||||
|
shortDotsEnable: false,
|
||||||
|
metaFields: ['_id', '_type', '_score'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultParams: OnlyEsqlQueryRuleParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 5,
|
||||||
|
timeWindowUnit: 'm',
|
||||||
|
thresholdComparator: Comparator.GT,
|
||||||
|
threshold: [0],
|
||||||
|
esqlQuery: { esql: 'from test' },
|
||||||
|
excludeHitsFromPreviousRun: false,
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
aggType: 'count',
|
||||||
|
groupBy: 'all',
|
||||||
|
timeField: 'time',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('fetchEsqlQuery', () => {
|
||||||
|
describe('getEsqlQuery', () => {
|
||||||
|
const dataViewMock = createDataView();
|
||||||
|
afterAll(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeNow = new Date('2020-02-09T23:15:41.941Z');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
global.Date.now = jest.fn(() => fakeNow.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the correct query', async () => {
|
||||||
|
const params = defaultParams;
|
||||||
|
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, undefined);
|
||||||
|
|
||||||
|
expect(query).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"filter": Object {
|
||||||
|
"bool": Object {
|
||||||
|
"filter": Array [
|
||||||
|
Object {
|
||||||
|
"range": Object {
|
||||||
|
"time": Object {
|
||||||
|
"format": "strict_date_optional_time",
|
||||||
|
"gt": "2020-02-09T23:10:41.941Z",
|
||||||
|
"lte": "2020-02-09T23:15:41.941Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"query": "from test",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
|
||||||
|
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the correct query with the alertLimit', async () => {
|
||||||
|
const params = defaultParams;
|
||||||
|
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, 100);
|
||||||
|
|
||||||
|
expect(query).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"filter": Object {
|
||||||
|
"bool": Object {
|
||||||
|
"filter": Array [
|
||||||
|
Object {
|
||||||
|
"range": Object {
|
||||||
|
"time": Object {
|
||||||
|
"format": "strict_date_optional_time",
|
||||||
|
"gt": "2020-02-09T23:10:41.941Z",
|
||||||
|
"lte": "2020-02-09T23:15:41.941Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"query": "from test | limit 100",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
|
||||||
|
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* 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 { DataView, DataViewsContract, getTime } from '@kbn/data-plugin/common';
|
||||||
|
import { parseAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
|
||||||
|
import { SharePluginStart } from '@kbn/share-plugin/server';
|
||||||
|
import { IScopedClusterClient, Logger } from '@kbn/core/server';
|
||||||
|
import { OnlyEsqlQueryRuleParams } from '../types';
|
||||||
|
import { EsqlTable, toEsQueryHits } from '../../../../common';
|
||||||
|
|
||||||
|
export interface FetchEsqlQueryOpts {
|
||||||
|
ruleId: string;
|
||||||
|
alertLimit: number | undefined;
|
||||||
|
params: OnlyEsqlQueryRuleParams;
|
||||||
|
spacePrefix: string;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
services: {
|
||||||
|
logger: Logger;
|
||||||
|
scopedClusterClient: IScopedClusterClient;
|
||||||
|
share: SharePluginStart;
|
||||||
|
dataViews: DataViewsContract;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEsqlQuery({
|
||||||
|
ruleId,
|
||||||
|
alertLimit,
|
||||||
|
params,
|
||||||
|
services,
|
||||||
|
spacePrefix,
|
||||||
|
publicBaseUrl,
|
||||||
|
}: FetchEsqlQueryOpts) {
|
||||||
|
const { logger, scopedClusterClient, dataViews } = services;
|
||||||
|
const esClient = scopedClusterClient.asCurrentUser;
|
||||||
|
const dataView = await dataViews.create({
|
||||||
|
timeFieldName: params.timeField,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { query, dateStart, dateEnd } = getEsqlQuery(dataView, params, alertLimit);
|
||||||
|
|
||||||
|
logger.debug(`ESQL query rule (${ruleId}) query: ${JSON.stringify(query)}`);
|
||||||
|
|
||||||
|
const response = await esClient.transport.request<EsqlTable>({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/_esql',
|
||||||
|
body: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = `${publicBaseUrl}${spacePrefix}/app/management/insightsAndAlerting/triggersActions/rule/${ruleId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
numMatches: Number(response.values.length),
|
||||||
|
parsedResults: parseAggregationResults({
|
||||||
|
isCountAgg: true,
|
||||||
|
isGroupAgg: false,
|
||||||
|
esResult: {
|
||||||
|
took: 0,
|
||||||
|
timed_out: false,
|
||||||
|
_shards: { failed: 0, successful: 0, total: 0 },
|
||||||
|
hits: toEsQueryHits(response),
|
||||||
|
},
|
||||||
|
resultLimit: alertLimit,
|
||||||
|
}),
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEsqlQuery = (
|
||||||
|
dataView: DataView,
|
||||||
|
params: OnlyEsqlQueryRuleParams,
|
||||||
|
alertLimit: number | undefined
|
||||||
|
) => {
|
||||||
|
const timeRange = {
|
||||||
|
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
|
||||||
|
to: 'now',
|
||||||
|
};
|
||||||
|
const timerangeFilter = getTime(dataView, timeRange);
|
||||||
|
const dateStart = timerangeFilter?.query.range[params.timeField].gte;
|
||||||
|
const dateEnd = timerangeFilter?.query.range[params.timeField].lte;
|
||||||
|
const rangeFilter: unknown[] = [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
[params.timeField]: {
|
||||||
|
lte: dateEnd,
|
||||||
|
gt: dateStart,
|
||||||
|
format: 'strict_date_optional_time',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query: alertLimit ? `${params.esqlQuery.esql} | limit ${alertLimit}` : params.esqlQuery.esql,
|
||||||
|
filter: {
|
||||||
|
bool: {
|
||||||
|
filter: rangeFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
};
|
||||||
|
};
|
|
@ -19,7 +19,11 @@ import type { ESSearchResponse, ESSearchRequest } from '@kbn/es-types';
|
||||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||||
import { coreMock } from '@kbn/core/server/mocks';
|
import { coreMock } from '@kbn/core/server/mocks';
|
||||||
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
|
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
|
||||||
import { OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types';
|
import {
|
||||||
|
OnlyEsqlQueryRuleParams,
|
||||||
|
OnlyEsQueryRuleParams,
|
||||||
|
OnlySearchSourceRuleParams,
|
||||||
|
} from './types';
|
||||||
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
|
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
|
||||||
import { Comparator } from '../../../common/comparator_types';
|
import { Comparator } from '../../../common/comparator_types';
|
||||||
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings';
|
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings';
|
||||||
|
@ -108,6 +112,10 @@ describe('ruleType', () => {
|
||||||
"description": "The indices the rule queries.",
|
"description": "The indices the rule queries.",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"description": "ESQL query field used to fetch data from Elasticsearch.",
|
||||||
|
"name": "esqlQuery",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
@ -596,10 +604,6 @@ describe('ruleType', () => {
|
||||||
groupBy: 'all',
|
groupBy: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validator succeeds with valid search source params', async () => {
|
it('validator succeeds with valid search source params', async () => {
|
||||||
expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy();
|
expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
@ -710,6 +714,144 @@ describe('ruleType', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ESQL query', () => {
|
||||||
|
const dataViewMock = {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'test-title',
|
||||||
|
timeFieldName: 'time-field',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
type: 'string',
|
||||||
|
displayName: 'message',
|
||||||
|
scripted: false,
|
||||||
|
filterable: false,
|
||||||
|
aggregatable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timestamp',
|
||||||
|
type: 'date',
|
||||||
|
displayName: 'timestamp',
|
||||||
|
scripted: false,
|
||||||
|
filterable: false,
|
||||||
|
aggregatable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toSpec: () => {
|
||||||
|
return { id: 'test-id', title: 'test-title', timeFieldName: 'timestamp', fields: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaultParams: OnlyEsqlQueryRuleParams = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 5,
|
||||||
|
timeWindowUnit: 'm',
|
||||||
|
thresholdComparator: Comparator.GT,
|
||||||
|
threshold: [0],
|
||||||
|
esqlQuery: { esql: 'test' },
|
||||||
|
timeField: 'timestamp',
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
excludeHitsFromPreviousRun: true,
|
||||||
|
aggType: 'count',
|
||||||
|
groupBy: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('validator succeeds with valid ESQL query params', async () => {
|
||||||
|
expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validator fails with invalid ESQL query params - esQuery provided', async () => {
|
||||||
|
const paramsSchema = ruleType.validate.params;
|
||||||
|
const params: Partial<Writable<EsQueryRuleParams>> = {
|
||||||
|
size: 100,
|
||||||
|
timeWindowSize: 5,
|
||||||
|
timeWindowUnit: 'm',
|
||||||
|
thresholdComparator: Comparator.LT,
|
||||||
|
threshold: [0],
|
||||||
|
esQuery: '',
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
aggType: 'count',
|
||||||
|
groupBy: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"[esQuery]: a value wasn't expected to be present"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rule executor handles no documents returned by ES', async () => {
|
||||||
|
const params = defaultParams;
|
||||||
|
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
|
||||||
|
|
||||||
|
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
|
||||||
|
...dataViewMock.toSpec(),
|
||||||
|
toSpec: () => dataViewMock.toSpec(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResult = {
|
||||||
|
columns: [
|
||||||
|
{ name: 'timestamp', type: 'date' },
|
||||||
|
{ name: 'message', type: 'keyword' },
|
||||||
|
],
|
||||||
|
values: [],
|
||||||
|
};
|
||||||
|
ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce(
|
||||||
|
searchResult
|
||||||
|
);
|
||||||
|
|
||||||
|
await invokeExecutor({ params, ruleServices });
|
||||||
|
expect(ruleServices.alertsClient.report).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rule executor schedule actions when condition met', async () => {
|
||||||
|
const params = defaultParams;
|
||||||
|
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
|
||||||
|
|
||||||
|
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
|
||||||
|
...dataViewMock.toSpec(),
|
||||||
|
toSpec: () => dataViewMock.toSpec(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResult = {
|
||||||
|
columns: [
|
||||||
|
{ name: 'timestamp', type: 'date' },
|
||||||
|
{ name: 'message', type: 'keyword' },
|
||||||
|
],
|
||||||
|
values: [
|
||||||
|
['timestamp', 'message'],
|
||||||
|
['timestamp', 'message'],
|
||||||
|
['timestamp', 'message'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce(
|
||||||
|
searchResult
|
||||||
|
);
|
||||||
|
|
||||||
|
await invokeExecutor({ params, ruleServices });
|
||||||
|
|
||||||
|
expect(ruleServices.alertsClient.report).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(ruleServices.alertsClient.report).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
actionGroup: 'query matched',
|
||||||
|
id: 'query matched',
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
kibana: {
|
||||||
|
alert: {
|
||||||
|
url: expect.any(String),
|
||||||
|
reason: expect.any(String),
|
||||||
|
title: "rule 'rule-name' matched query",
|
||||||
|
evaluation: {
|
||||||
|
conditions: 'Query matched documents',
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateResults(
|
function generateResults(
|
||||||
|
@ -756,7 +898,7 @@ async function invokeExecutor({
|
||||||
ruleServices,
|
ruleServices,
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams;
|
params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams | OnlyEsqlQueryRuleParams;
|
||||||
ruleServices: RuleExecutorServicesMock;
|
ruleServices: RuleExecutorServicesMock;
|
||||||
state?: EsQueryRuleState;
|
state?: EsQueryRuleState;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common';
|
||||||
import { ExecutorOptions } from './types';
|
import { ExecutorOptions } from './types';
|
||||||
import { ActionGroupId, ES_QUERY_ID } from './constants';
|
import { ActionGroupId, ES_QUERY_ID } from './constants';
|
||||||
import { executor } from './executor';
|
import { executor } from './executor';
|
||||||
import { isEsQueryRule } from './util';
|
import { isSearchSourceRule } from './util';
|
||||||
|
|
||||||
export function getRuleType(
|
export function getRuleType(
|
||||||
core: CoreSetup
|
core: CoreSetup
|
||||||
|
@ -134,6 +134,13 @@ export function getRuleType(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const actionVariableEsqlQueryLabel = i18n.translate(
|
||||||
|
'xpack.stackAlerts.esQuery.actionVariableContextEsqlQueryLabel',
|
||||||
|
{
|
||||||
|
defaultMessage: 'ESQL query field used to fetch data from Elasticsearch.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const actionVariableContextLinkLabel = i18n.translate(
|
const actionVariableContextLinkLabel = i18n.translate(
|
||||||
'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel',
|
'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel',
|
||||||
{
|
{
|
||||||
|
@ -179,25 +186,27 @@ export function getRuleType(
|
||||||
{ name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel },
|
{ name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel },
|
||||||
{ name: 'esQuery', description: actionVariableContextQueryLabel },
|
{ name: 'esQuery', description: actionVariableContextQueryLabel },
|
||||||
{ name: 'index', description: actionVariableContextIndexLabel },
|
{ name: 'index', description: actionVariableContextIndexLabel },
|
||||||
|
{ name: 'esqlQuery', description: actionVariableEsqlQueryLabel },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
useSavedObjectReferences: {
|
useSavedObjectReferences: {
|
||||||
extractReferences: (params) => {
|
extractReferences: (params) => {
|
||||||
if (isEsQueryRule(params.searchType)) {
|
if (isSearchSourceRule(params.searchType)) {
|
||||||
return { params: params as EsQueryRuleParamsExtractedParams, references: [] };
|
const [searchConfiguration, references] = extractReferences(params.searchConfiguration);
|
||||||
|
const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams;
|
||||||
|
return { params: newParams, references };
|
||||||
}
|
}
|
||||||
const [searchConfiguration, references] = extractReferences(params.searchConfiguration);
|
|
||||||
const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams;
|
return { params: params as EsQueryRuleParamsExtractedParams, references: [] };
|
||||||
return { params: newParams, references };
|
|
||||||
},
|
},
|
||||||
injectReferences: (params, references) => {
|
injectReferences: (params, references) => {
|
||||||
if (isEsQueryRule(params.searchType)) {
|
if (isSearchSourceRule(params.searchType)) {
|
||||||
return params;
|
return {
|
||||||
|
...params,
|
||||||
|
searchConfiguration: injectReferences(params.searchConfiguration, references),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return params;
|
||||||
...params,
|
|
||||||
searchConfiguration: injectReferences(params.searchConfiguration, references),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
minimumLicenseRequired: 'basic',
|
minimumLicenseRequired: 'basic',
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
||||||
import { ComparatorFnNames } from '../../../common';
|
import { ComparatorFnNames } from '../../../common';
|
||||||
import { Comparator } from '../../../common/comparator_types';
|
import { Comparator } from '../../../common/comparator_types';
|
||||||
import { getComparatorSchemaType } from '../lib/comparator';
|
import { getComparatorSchemaType } from '../lib/comparator';
|
||||||
|
import { isEsqlQueryRule, isSearchSourceRule } from './util';
|
||||||
|
|
||||||
export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000;
|
export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000;
|
||||||
|
|
||||||
|
@ -50,9 +51,12 @@ const EsQueryRuleParamsSchemaProperties = {
|
||||||
termField: schema.maybe(schema.string({ minLength: 1 })),
|
termField: schema.maybe(schema.string({ minLength: 1 })),
|
||||||
// limit on number of groups returned
|
// limit on number of groups returned
|
||||||
termSize: schema.maybe(schema.number({ min: 1 })),
|
termSize: schema.maybe(schema.number({ min: 1 })),
|
||||||
searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], {
|
searchType: schema.oneOf(
|
||||||
defaultValue: 'esQuery',
|
[schema.literal('searchSource'), schema.literal('esQuery'), schema.literal('esqlQuery')],
|
||||||
}),
|
{
|
||||||
|
defaultValue: 'esQuery',
|
||||||
|
}
|
||||||
|
),
|
||||||
timeField: schema.conditional(
|
timeField: schema.conditional(
|
||||||
schema.siblingRef('searchType'),
|
schema.siblingRef('searchType'),
|
||||||
schema.literal('esQuery'),
|
schema.literal('esQuery'),
|
||||||
|
@ -79,6 +83,13 @@ const EsQueryRuleParamsSchemaProperties = {
|
||||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||||
schema.never()
|
schema.never()
|
||||||
),
|
),
|
||||||
|
// esqlQuery rule params only
|
||||||
|
esqlQuery: schema.conditional(
|
||||||
|
schema.siblingRef('searchType'),
|
||||||
|
schema.literal('esqlQuery'),
|
||||||
|
schema.object({ esql: schema.string({ minLength: 1 }) }),
|
||||||
|
schema.never()
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EsQueryRuleParamsSchema = schema.object(EsQueryRuleParamsSchemaProperties, {
|
export const EsQueryRuleParamsSchema = schema.object(EsQueryRuleParamsSchemaProperties, {
|
||||||
|
@ -142,7 +153,7 @@ function validateParams(anyParams: unknown): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchType === 'searchSource') {
|
if (isSearchSourceRule(searchType) || isEsqlQueryRule(searchType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,15 +11,26 @@ import { ActionContext } from './action_context';
|
||||||
import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params';
|
import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params';
|
||||||
import { ActionGroupId } from './constants';
|
import { ActionGroupId } from './constants';
|
||||||
|
|
||||||
export type OnlyEsQueryRuleParams = Omit<EsQueryRuleParams, 'searchConfiguration'> & {
|
export type OnlyEsQueryRuleParams = Omit<EsQueryRuleParams, 'searchConfiguration' | 'esqlQuery'> & {
|
||||||
searchType: 'esQuery';
|
searchType: 'esQuery';
|
||||||
timeField: string;
|
timeField: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OnlySearchSourceRuleParams = Omit<EsQueryRuleParams, 'esQuery' | 'index'> & {
|
export type OnlySearchSourceRuleParams = Omit<
|
||||||
|
EsQueryRuleParams,
|
||||||
|
'esQuery' | 'index' | 'esqlQuery'
|
||||||
|
> & {
|
||||||
searchType: 'searchSource';
|
searchType: 'searchSource';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OnlyEsqlQueryRuleParams = Omit<
|
||||||
|
EsQueryRuleParams,
|
||||||
|
'esQuery' | 'index' | 'searchConfiguration'
|
||||||
|
> & {
|
||||||
|
searchType: 'esqlQuery';
|
||||||
|
timeField: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutorOptions<P extends RuleTypeParams> = RuleExecutorOptions<
|
export type ExecutorOptions<P extends RuleTypeParams> = RuleExecutorOptions<
|
||||||
P,
|
P,
|
||||||
EsQueryRuleState,
|
EsQueryRuleState,
|
||||||
|
|
|
@ -8,5 +8,13 @@
|
||||||
import { EsQueryRuleParams } from './rule_type_params';
|
import { EsQueryRuleParams } from './rule_type_params';
|
||||||
|
|
||||||
export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) {
|
export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) {
|
||||||
return searchType !== 'searchSource';
|
return searchType === 'esQuery';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSearchSourceRule(searchType: EsQueryRuleParams['searchType']) {
|
||||||
|
return searchType === 'searchSource';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEsqlQueryRule(searchType: EsQueryRuleParams['searchType']) {
|
||||||
|
return searchType === 'esqlQuery';
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,9 @@
|
||||||
"@kbn/discover-plugin",
|
"@kbn/discover-plugin",
|
||||||
"@kbn/rule-data-utils",
|
"@kbn/rule-data-utils",
|
||||||
"@kbn/alerts-as-data-utils",
|
"@kbn/alerts-as-data-utils",
|
||||||
|
"@kbn/text-based-languages",
|
||||||
|
"@kbn/text-based-editor",
|
||||||
|
"@kbn/expressions-plugin",
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"target/**/*",
|
"target/**/*",
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"actions",
|
"actions",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"licensing",
|
"licensing",
|
||||||
|
"expressions"
|
||||||
],
|
],
|
||||||
"optionalPlugins": [
|
"optionalPlugins": [
|
||||||
"cloud",
|
"cloud",
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
|
||||||
import { ruleDetailsRoute } from '@kbn/rule-data-utils';
|
import { ruleDetailsRoute } from '@kbn/rule-data-utils';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { DashboardStart } from '@kbn/dashboard-plugin/public';
|
import { DashboardStart } from '@kbn/dashboard-plugin/public';
|
||||||
|
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||||
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
|
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
|
||||||
import {
|
import {
|
||||||
ActionTypeRegistryContract,
|
ActionTypeRegistryContract,
|
||||||
|
@ -70,6 +71,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
|
||||||
theme$: Observable<CoreTheme>;
|
theme$: Observable<CoreTheme>;
|
||||||
unifiedSearch: UnifiedSearchPublicPluginStart;
|
unifiedSearch: UnifiedSearchPublicPluginStart;
|
||||||
licensing: LicensingPluginStart;
|
licensing: LicensingPluginStart;
|
||||||
|
expressions: ExpressionsStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderApp = (deps: TriggersAndActionsUiServices) => {
|
export const renderApp = (deps: TriggersAndActionsUiServices) => {
|
||||||
|
|
|
@ -24,7 +24,7 @@ export {
|
||||||
export { connectorDeprecatedMessage, deprecatedMessage } from './connectors_selection';
|
export { connectorDeprecatedMessage, deprecatedMessage } from './connectors_selection';
|
||||||
export type { IOption } from './index_controls';
|
export type { IOption } from './index_controls';
|
||||||
export { getFields, getIndexOptions, firstFieldOption } from './index_controls';
|
export { getFields, getIndexOptions, firstFieldOption } from './index_controls';
|
||||||
export { getTimeFieldOptions, useKibana } from './lib';
|
export { getTimeFieldOptions, getTimeOptions, useKibana } from './lib';
|
||||||
export type {
|
export type {
|
||||||
Comparator,
|
Comparator,
|
||||||
AggregationType,
|
AggregationType,
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
AlertsTableConfigurationRegistryContract,
|
AlertsTableConfigurationRegistryContract,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
|
import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
|
||||||
|
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
|
||||||
|
|
||||||
export const createStartServicesMock = (): TriggersAndActionsUiServices => {
|
export const createStartServicesMock = (): TriggersAndActionsUiServices => {
|
||||||
const core = coreMock.createStart();
|
const core = coreMock.createStart();
|
||||||
|
@ -70,6 +71,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
|
||||||
} as unknown as HTMLElement,
|
} as unknown as HTMLElement,
|
||||||
theme$: themeServiceMock.createTheme$(),
|
theme$: themeServiceMock.createTheme$(),
|
||||||
licensing: licensingPluginMock,
|
licensing: licensingPluginMock,
|
||||||
|
expressions: expressionsPluginMock.createStartContract(),
|
||||||
} as TriggersAndActionsUiServices;
|
} as TriggersAndActionsUiServices;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -116,6 +116,7 @@ export {
|
||||||
getIndexOptions,
|
getIndexOptions,
|
||||||
firstFieldOption,
|
firstFieldOption,
|
||||||
getTimeFieldOptions,
|
getTimeFieldOptions,
|
||||||
|
getTimeOptions,
|
||||||
GroupByExpression,
|
GroupByExpression,
|
||||||
COMPARATORS,
|
COMPARATORS,
|
||||||
connectorDeprecatedMessage,
|
connectorDeprecatedMessage,
|
||||||
|
|
|
@ -26,6 +26,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
|
||||||
import { triggersActionsRoute } from '@kbn/rule-data-utils';
|
import { triggersActionsRoute } from '@kbn/rule-data-utils';
|
||||||
import { DashboardStart } from '@kbn/dashboard-plugin/public';
|
import { DashboardStart } from '@kbn/dashboard-plugin/public';
|
||||||
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
|
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
|
||||||
|
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||||
import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar';
|
import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar';
|
||||||
import { TypeRegistry } from './application/type_registry';
|
import { TypeRegistry } from './application/type_registry';
|
||||||
|
|
||||||
|
@ -161,6 +162,7 @@ interface PluginsStart {
|
||||||
spaces?: SpacesPluginStart;
|
spaces?: SpacesPluginStart;
|
||||||
navigateToApp: CoreStart['application']['navigateToApp'];
|
navigateToApp: CoreStart['application']['navigateToApp'];
|
||||||
features: FeaturesPluginStart;
|
features: FeaturesPluginStart;
|
||||||
|
expressions: ExpressionsStart;
|
||||||
unifiedSearch: UnifiedSearchPublicPluginStart;
|
unifiedSearch: UnifiedSearchPublicPluginStart;
|
||||||
licensing: LicensingPluginStart;
|
licensing: LicensingPluginStart;
|
||||||
}
|
}
|
||||||
|
@ -287,6 +289,7 @@ export class Plugin
|
||||||
alertsTableConfigurationRegistry,
|
alertsTableConfigurationRegistry,
|
||||||
kibanaFeatures,
|
kibanaFeatures,
|
||||||
licensing: pluginsStart.licensing,
|
licensing: pluginsStart.licensing,
|
||||||
|
expressions: pluginsStart.expressions,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
"@kbn/core-ui-settings-common",
|
"@kbn/core-ui-settings-common",
|
||||||
"@kbn/dashboard-plugin",
|
"@kbn/dashboard-plugin",
|
||||||
"@kbn/licensing-plugin",
|
"@kbn/licensing-plugin",
|
||||||
|
"@kbn/expressions-plugin",
|
||||||
],
|
],
|
||||||
"exclude": ["target/**/*"]
|
"exclude": ["target/**/*"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,340 @@
|
||||||
|
/*
|
||||||
|
* 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 expect from '@kbn/expect';
|
||||||
|
import { Spaces } from '../../../../../scenarios';
|
||||||
|
import { FtrProviderContext } from '../../../../../../common/ftr_provider_context';
|
||||||
|
import { getUrlPrefix, ObjectRemover } from '../../../../../../common/lib';
|
||||||
|
import {
|
||||||
|
createConnector,
|
||||||
|
ES_GROUPS_TO_WRITE,
|
||||||
|
ES_TEST_DATA_STREAM_NAME,
|
||||||
|
ES_TEST_INDEX_REFERENCE,
|
||||||
|
ES_TEST_INDEX_SOURCE,
|
||||||
|
ES_TEST_OUTPUT_INDEX_NAME,
|
||||||
|
getRuleServices,
|
||||||
|
RULE_INTERVALS_TO_WRITE,
|
||||||
|
RULE_INTERVAL_MILLIS,
|
||||||
|
RULE_INTERVAL_SECONDS,
|
||||||
|
RULE_TYPE_ID,
|
||||||
|
} from './common';
|
||||||
|
import { createDataStream, deleteDataStream } from '../../../create_test_data';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default function ruleTests({ getService }: FtrProviderContext) {
|
||||||
|
const supertest = getService('supertest');
|
||||||
|
const {
|
||||||
|
es,
|
||||||
|
esTestIndexTool,
|
||||||
|
esTestIndexToolOutput,
|
||||||
|
esTestIndexToolDataStream,
|
||||||
|
createEsDocumentsInGroups,
|
||||||
|
removeAllAADDocs,
|
||||||
|
getAllAADDocs,
|
||||||
|
} = getRuleServices(getService);
|
||||||
|
|
||||||
|
describe('rule', async () => {
|
||||||
|
let endDate: string;
|
||||||
|
let connectorId: string;
|
||||||
|
const objectRemover = new ObjectRemover(supertest);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await esTestIndexTool.destroy();
|
||||||
|
await esTestIndexTool.setup();
|
||||||
|
|
||||||
|
await esTestIndexToolOutput.destroy();
|
||||||
|
await esTestIndexToolOutput.setup();
|
||||||
|
|
||||||
|
connectorId = await createConnector(supertest, objectRemover, ES_TEST_OUTPUT_INDEX_NAME);
|
||||||
|
|
||||||
|
// write documents in the future, figure out the end date
|
||||||
|
const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS;
|
||||||
|
endDate = new Date(endDateMillis).toISOString();
|
||||||
|
|
||||||
|
await createDataStream(es, ES_TEST_DATA_STREAM_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await objectRemover.removeAll();
|
||||||
|
await esTestIndexTool.destroy();
|
||||||
|
await esTestIndexToolOutput.destroy();
|
||||||
|
await deleteDataStream(es, ES_TEST_DATA_STREAM_NAME);
|
||||||
|
await removeAllAADDocs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs correctly: threshold on ungrouped hit count < >', async () => {
|
||||||
|
// write documents from now to the future end date in groups
|
||||||
|
await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate);
|
||||||
|
await createRule({
|
||||||
|
name: 'never fire',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
await createRule({
|
||||||
|
name: 'always fire',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = await waitForDocs(2);
|
||||||
|
const messagePattern =
|
||||||
|
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
|
||||||
|
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
const doc = docs[i];
|
||||||
|
const { hits } = doc._source;
|
||||||
|
const { name, title, message } = doc._source.params;
|
||||||
|
|
||||||
|
expect(name).to.be('always fire');
|
||||||
|
expect(title).to.be(`rule 'always fire' matched query`);
|
||||||
|
expect(message).to.match(messagePattern);
|
||||||
|
expect(hits).not.to.be.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const aadDocs = await getAllAADDocs(1);
|
||||||
|
|
||||||
|
const alertDoc = aadDocs.body.hits.hits[0]._source.kibana.alert;
|
||||||
|
expect(alertDoc.reason).to.match(messagePattern);
|
||||||
|
expect(alertDoc.title).to.be("rule 'always fire' matched query");
|
||||||
|
expect(alertDoc.evaluation.conditions).to.be('Query matched documents');
|
||||||
|
expect(alertDoc.evaluation.value).greaterThan(0);
|
||||||
|
expect(alertDoc.url).to.contain('/s/space1/app/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs correctly: use epoch millis - threshold on hit count < >', async () => {
|
||||||
|
// write documents from now to the future end date in groups
|
||||||
|
const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS;
|
||||||
|
endDate = new Date(endDateMillis).toISOString();
|
||||||
|
await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate);
|
||||||
|
await createRule({
|
||||||
|
name: 'never fire',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0',
|
||||||
|
size: 100,
|
||||||
|
timeField: 'date_epoch_millis',
|
||||||
|
});
|
||||||
|
await createRule({
|
||||||
|
name: 'always fire',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1',
|
||||||
|
size: 100,
|
||||||
|
timeField: 'date_epoch_millis',
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = await waitForDocs(2);
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
const doc = docs[i];
|
||||||
|
const { hits } = doc._source;
|
||||||
|
const { name, title, message } = doc._source.params;
|
||||||
|
|
||||||
|
expect(name).to.be('always fire');
|
||||||
|
expect(title).to.be(`rule 'always fire' matched query`);
|
||||||
|
const messagePattern =
|
||||||
|
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
|
||||||
|
expect(message).to.match(messagePattern);
|
||||||
|
expect(hits).not.to.be.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs correctly: no matches', async () => {
|
||||||
|
await createRule({
|
||||||
|
name: 'always fire',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = await waitForDocs(1);
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
const doc = docs[i];
|
||||||
|
const { hits } = doc._source;
|
||||||
|
const { name, title, message } = doc._source.params;
|
||||||
|
|
||||||
|
expect(name).to.be('always fire');
|
||||||
|
expect(title).to.be(`rule 'always fire' matched query`);
|
||||||
|
const messagePattern =
|
||||||
|
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
|
||||||
|
expect(message).to.match(messagePattern);
|
||||||
|
expect(hits).not.to.be.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs correctly and populates recovery context', async () => {
|
||||||
|
// This rule should be active initially when the number of documents is below the threshold
|
||||||
|
// and then recover when we add more documents.
|
||||||
|
await createRule({
|
||||||
|
name: 'fire then recovers',
|
||||||
|
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1',
|
||||||
|
size: 100,
|
||||||
|
notifyWhen: 'onActionGroupChange',
|
||||||
|
timeWindowSize: RULE_INTERVAL_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
let docs = await waitForDocs(1);
|
||||||
|
const activeDoc = docs[0];
|
||||||
|
const {
|
||||||
|
name: activeName,
|
||||||
|
title: activeTitle,
|
||||||
|
value: activeValue,
|
||||||
|
message: activeMessage,
|
||||||
|
} = activeDoc._source.params;
|
||||||
|
|
||||||
|
expect(activeName).to.be('fire then recovers');
|
||||||
|
expect(activeTitle).to.be(`rule 'fire then recovers' matched query`);
|
||||||
|
expect(activeValue).to.be('1');
|
||||||
|
expect(activeMessage).to.match(
|
||||||
|
/rule 'fire then recovers' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/
|
||||||
|
);
|
||||||
|
await createEsDocumentsInGroups(1, endDate);
|
||||||
|
docs = await waitForDocs(2);
|
||||||
|
const recoveredDoc = docs[1];
|
||||||
|
const {
|
||||||
|
name: recoveredName,
|
||||||
|
title: recoveredTitle,
|
||||||
|
message: recoveredMessage,
|
||||||
|
} = recoveredDoc._source.params;
|
||||||
|
|
||||||
|
expect(recoveredName).to.be('fire then recovers');
|
||||||
|
expect(recoveredTitle).to.be(`rule 'fire then recovers' recovered`);
|
||||||
|
expect(recoveredMessage).to.match(
|
||||||
|
/rule 'fire then recovers' is recovered:\n\n- Value: \d+\n- Conditions Met: Query did NOT match documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs correctly over a data stream: threshold on hit count < >', async () => {
|
||||||
|
// write documents from now to the future end date in groups
|
||||||
|
await createEsDocumentsInGroups(
|
||||||
|
ES_GROUPS_TO_WRITE,
|
||||||
|
endDate,
|
||||||
|
esTestIndexToolDataStream,
|
||||||
|
ES_TEST_DATA_STREAM_NAME
|
||||||
|
);
|
||||||
|
await createRule({
|
||||||
|
name: 'never fire',
|
||||||
|
esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c < 0',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
await createRule({
|
||||||
|
name: 'always fire',
|
||||||
|
esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c > -1',
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = await waitForDocs(2);
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
const doc = docs[i];
|
||||||
|
const { hits } = doc._source;
|
||||||
|
const { name, title, message } = doc._source.params;
|
||||||
|
|
||||||
|
expect(name).to.be('always fire');
|
||||||
|
expect(title).to.be(`rule 'always fire' matched query`);
|
||||||
|
const messagePattern =
|
||||||
|
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
|
||||||
|
expect(message).to.match(messagePattern);
|
||||||
|
expect(hits).not.to.be.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitForDocs(count: number): Promise<any[]> {
|
||||||
|
return await esTestIndexToolOutput.waitForDocs(
|
||||||
|
ES_TEST_INDEX_SOURCE,
|
||||||
|
ES_TEST_INDEX_REFERENCE,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateRuleParams {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
esqlQuery: string;
|
||||||
|
timeWindowSize?: number;
|
||||||
|
timeField?: string;
|
||||||
|
notifyWhen?: string;
|
||||||
|
aggType?: string;
|
||||||
|
aggField?: string;
|
||||||
|
groupBy?: string;
|
||||||
|
termField?: string;
|
||||||
|
termSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRule(params: CreateRuleParams): Promise<string> {
|
||||||
|
const action = {
|
||||||
|
id: connectorId,
|
||||||
|
group: 'query matched',
|
||||||
|
params: {
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
source: ES_TEST_INDEX_SOURCE,
|
||||||
|
reference: ES_TEST_INDEX_REFERENCE,
|
||||||
|
params: {
|
||||||
|
name: '{{{rule.name}}}',
|
||||||
|
value: '{{{context.value}}}',
|
||||||
|
title: '{{{context.title}}}',
|
||||||
|
message: '{{{context.message}}}',
|
||||||
|
},
|
||||||
|
hits: '{{context.hits}}',
|
||||||
|
date: '{{{context.date}}}',
|
||||||
|
previousTimestamp: '{{{state.latestTimestamp}}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoveryAction = {
|
||||||
|
id: connectorId,
|
||||||
|
group: 'recovered',
|
||||||
|
params: {
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
source: ES_TEST_INDEX_SOURCE,
|
||||||
|
reference: ES_TEST_INDEX_REFERENCE,
|
||||||
|
params: {
|
||||||
|
name: '{{{rule.name}}}',
|
||||||
|
value: '{{{context.value}}}',
|
||||||
|
title: '{{{context.title}}}',
|
||||||
|
message: '{{{context.message}}}',
|
||||||
|
},
|
||||||
|
hits: '{{context.hits}}',
|
||||||
|
date: '{{{context.date}}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { body: createdRule } = await supertest
|
||||||
|
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
|
||||||
|
.set('kbn-xsrf', 'foo')
|
||||||
|
.send({
|
||||||
|
name: params.name,
|
||||||
|
consumer: 'alerts',
|
||||||
|
enabled: true,
|
||||||
|
rule_type_id: RULE_TYPE_ID,
|
||||||
|
schedule: { interval: `${RULE_INTERVAL_SECONDS}s` },
|
||||||
|
actions: [action, recoveryAction],
|
||||||
|
notify_when: params.notifyWhen || 'onActiveAlert',
|
||||||
|
params: {
|
||||||
|
size: params.size,
|
||||||
|
timeWindowSize: params.timeWindowSize || RULE_INTERVAL_SECONDS * 5,
|
||||||
|
timeWindowUnit: 's',
|
||||||
|
thresholdComparator: '>',
|
||||||
|
threshold: [0],
|
||||||
|
searchType: 'esqlQuery',
|
||||||
|
aggType: params.aggType,
|
||||||
|
groupBy: params.groupBy,
|
||||||
|
aggField: params.aggField,
|
||||||
|
termField: params.termField,
|
||||||
|
termSize: params.termSize,
|
||||||
|
timeField: params.timeField || 'date',
|
||||||
|
esqlQuery: { esql: params.esqlQuery },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const ruleId = createdRule.id;
|
||||||
|
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
|
||||||
|
|
||||||
|
return ruleId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
|
||||||
describe('es_query', () => {
|
describe('es_query', () => {
|
||||||
loadTestFile(require.resolve('./rule'));
|
loadTestFile(require.resolve('./rule'));
|
||||||
loadTestFile(require.resolve('./query_dsl_only'));
|
loadTestFile(require.resolve('./query_dsl_only'));
|
||||||
|
loadTestFile(require.resolve('./esql_only'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue