diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 37d04abda7530..4966a0b506317 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -11,7 +11,5 @@ jobs: uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: - issue-mappings: '[{"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}], {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"}]' + issue-mappings: '[{"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Feature:Canvas", "projectNumber": 38, "columnName": "Inbox"}, {"label": "Feature:Dashboard", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Drilldowns", "projectNumber": 68, "columnName": "Inbox"}, {"label": "Feature:Input Controls", "projectNumber": 72, "columnName": "Inbox"}]' ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} - - diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index 8603ca9935cac..fdcd71791ad3a 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -50,6 +50,16 @@ For large deployments with more than 10 {kib} instances and more than 10 000 sav ==== Preventing migration failures This section highlights common causes of {kib} upgrade failures and how to prevent them. +[float] +===== timeout_exception or receive_timeout_transport_exception +There is a known issue in v7.12.0 for users who tried the fleet beta. Upgrade migrations fail because of a large number of documents in the `.kibana` index. + +This can cause Kibana to log errors like: +> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [receive_timeout_transport_exception]: [instance-0000000002][10.32.1.112:19541][cluster:monitor/task/get] request_id [2648] timed out after [59940ms] +> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [timeout_exception]: Timed out waiting for completion of [org.elasticsearch.index.reindex.BulkByScrollTask@6a74c54] + +See https://github.com/elastic/kibana/issues/95321 for instructions to work around this issue. + [float] ===== Corrupt saved objects We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index e3c236405a596..a8063c317b3c5 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -6,27 +6,12 @@ * Side Public License, v 1. */ -import { configDeprecationFactory, applyDeprecations } from '@kbn/config'; +import { getDeprecationsForGlobalSettings } from '../test_utils'; import { coreDeprecationProvider } from './core_deprecations'; - const initialEnv = { ...process.env }; -const applyCoreDeprecations = (settings: Record = {}) => { - const deprecations = coreDeprecationProvider(configDeprecationFactory); - const deprecationMessages: string[] = []; - const migrated = applyDeprecations( - settings, - deprecations.map((deprecation) => ({ - deprecation, - path: '', - })), - () => ({ message }) => deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; +const applyCoreDeprecations = (settings?: Record) => + getDeprecationsForGlobalSettings({ provider: coreDeprecationProvider, settings }); describe('core deprecations', () => { beforeEach(() => { diff --git a/src/core/server/config/test_utils.ts b/src/core/server/config/test_utils.ts new file mode 100644 index 0000000000000..2eaf462768724 --- /dev/null +++ b/src/core/server/config/test_utils.ts @@ -0,0 +1,52 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { ConfigDeprecationProvider } from '@kbn/config'; +import { configDeprecationFactory, applyDeprecations } from '@kbn/config'; + +function collectDeprecations( + provider: ConfigDeprecationProvider, + settings: Record, + path: string +) { + const deprecations = provider(configDeprecationFactory); + const deprecationMessages: string[] = []; + const migrated = applyDeprecations( + settings, + deprecations.map((deprecation) => ({ + deprecation, + path, + })), + () => ({ message }) => deprecationMessages.push(message) + ); + return { + messages: deprecationMessages, + migrated, + }; +} + +export const getDeprecationsFor = ({ + provider, + settings = {}, + path, +}: { + provider: ConfigDeprecationProvider; + settings?: Record; + path: string; +}) => { + return collectDeprecations(provider, { [path]: settings }, path); +}; + +export const getDeprecationsForGlobalSettings = ({ + provider, + settings = {}, +}: { + provider: ConfigDeprecationProvider; + settings?: Record; +}) => { + return collectDeprecations(provider, settings, ''); +}; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index 23b804b535405..f8ef1a7a20a83 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -12,29 +12,17 @@ import { mockReadPkcs12Truststore, } from './elasticsearch_config.test.mocks'; -import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; import { ElasticsearchConfig, config } from './elasticsearch_config'; +import { getDeprecationsFor } from '../config/test_utils'; const CONFIG_PATH = 'elasticsearch'; -const applyElasticsearchDeprecations = (settings: Record = {}) => { - const deprecations = config.deprecations!(configDeprecationFactory); - const deprecationMessages: string[] = []; - const _config: any = {}; - _config[CONFIG_PATH] = settings; - const migrated = applyDeprecations( - _config, - deprecations.map((deprecation) => ({ - deprecation, - path: CONFIG_PATH, - })), - () => ({ message }) => deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; +const applyElasticsearchDeprecations = (settings: Record = {}) => + getDeprecationsFor({ + provider: config.deprecations!, + settings, + path: CONFIG_PATH, + }); test('set correct defaults', () => { const configValue = new ElasticsearchConfig(config.schema.validate({})); diff --git a/src/core/server/kibana_config.test.ts b/src/core/server/kibana_config.test.ts index 1acdff9dd78e6..47bb6cf2a064c 100644 --- a/src/core/server/kibana_config.test.ts +++ b/src/core/server/kibana_config.test.ts @@ -7,28 +7,16 @@ */ import { config } from './kibana_config'; -import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; +import { getDeprecationsFor } from './config/test_utils'; const CONFIG_PATH = 'kibana'; -const applyKibanaDeprecations = (settings: Record = {}) => { - const deprecations = config.deprecations!(configDeprecationFactory); - const deprecationMessages: string[] = []; - const _config: any = {}; - _config[CONFIG_PATH] = settings; - const migrated = applyDeprecations( - _config, - deprecations.map((deprecation) => ({ - deprecation, - path: CONFIG_PATH, - })), - () => ({ message }) => deprecationMessages.push(message) - ); - return { - messages: deprecationMessages, - migrated, - }; -}; +const applyKibanaDeprecations = (settings: Record = {}) => + getDeprecationsFor({ + provider: config.deprecations!, + settings, + path: CONFIG_PATH, + }); it('set correct defaults ', () => { const configValue = config.schema.validate({}); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index fd62fd107648e..4d41a147bc0ef 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -19,7 +19,8 @@ import { Root } from '../../../root'; const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; -describe('migration v2', () => { +// FLAKY: https://github.com/elastic/kibana/issues/91107 +describe.skip('migration v2', () => { let esServer: kbnTestServer.TestElasticsearchUtils; let root: Root; let coreStart: InternalCoreStart; diff --git a/src/core/server/saved_objects/saved_objects_config.test.ts b/src/core/server/saved_objects/saved_objects_config.test.ts new file mode 100644 index 0000000000000..720b28403edf2 --- /dev/null +++ b/src/core/server/saved_objects/saved_objects_config.test.ts @@ -0,0 +1,44 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { savedObjectsMigrationConfig } from './saved_objects_config'; +import { getDeprecationsFor } from '../config/test_utils'; + +const applyMigrationsDeprecations = (settings: Record = {}) => + getDeprecationsFor({ + provider: savedObjectsMigrationConfig.deprecations!, + settings, + path: 'migrations', + }); + +describe('migrations config', function () { + describe('deprecations', () => { + it('logs a warning if migrations.enableV2 is set: true', () => { + const { messages } = applyMigrationsDeprecations({ enableV2: true }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"migrations.enableV2\\" is deprecated and will be removed in an upcoming release without any further notice.", + ] + `); + }); + + it('logs a warning if migrations.enableV2 is set: false', () => { + const { messages } = applyMigrationsDeprecations({ enableV2: false }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"migrations.enableV2\\" is deprecated and will be removed in an upcoming release without any further notice.", + ] + `); + }); + }); + + it('does not log a warning if migrations.enableV2 is not set', () => { + const { messages } = applyMigrationsDeprecations({ batchSize: 1_000 }); + expect(messages).toMatchInlineSnapshot(`Array []`); + }); +}); diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 96fac85ded076..7182df74c597f 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -7,31 +7,50 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import type { ServiceConfigDescriptor } from '../internal_types'; +import type { ConfigDeprecationProvider } from '../config'; -export type SavedObjectsMigrationConfigType = TypeOf; +const migrationSchema = schema.object({ + batchSize: schema.number({ defaultValue: 1_000 }), + scrollDuration: schema.string({ defaultValue: '15m' }), + pollInterval: schema.number({ defaultValue: 1_500 }), + skip: schema.boolean({ defaultValue: false }), + enableV2: schema.boolean({ defaultValue: true }), + retryAttempts: schema.number({ defaultValue: 15 }), +}); -export const savedObjectsMigrationConfig = { +export type SavedObjectsMigrationConfigType = TypeOf; + +const migrationDeprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, addDeprecation) => { + const migrationsConfig = settings[fromPath]; + if (migrationsConfig?.enableV2 !== undefined) { + addDeprecation({ + message: + '"migrations.enableV2" is deprecated and will be removed in an upcoming release without any further notice.', + documentationUrl: 'https://ela.st/kbn-so-migration-v2', + }); + } + return settings; + }, +]; + +export const savedObjectsMigrationConfig: ServiceConfigDescriptor = { path: 'migrations', - schema: schema.object({ - batchSize: schema.number({ defaultValue: 1000 }), - scrollDuration: schema.string({ defaultValue: '15m' }), - pollInterval: schema.number({ defaultValue: 1500 }), - skip: schema.boolean({ defaultValue: false }), - // TODO migrationsV2: remove/deprecate once we release migrations v2 - enableV2: schema.boolean({ defaultValue: true }), - /** the number of times v2 migrations will retry temporary failures such as a timeout, 503 status code or snapshot_in_progress_exception */ - retryAttempts: schema.number({ defaultValue: 15 }), - }), + schema: migrationSchema, + deprecations: migrationDeprecations, }; -export type SavedObjectsConfigType = TypeOf; +const soSchema = schema.object({ + maxImportPayloadBytes: schema.byteSize({ defaultValue: 26_214_400 }), + maxImportExportSize: schema.number({ defaultValue: 10_000 }), +}); + +export type SavedObjectsConfigType = TypeOf; -export const savedObjectsConfig = { +export const savedObjectsConfig: ServiceConfigDescriptor = { path: 'savedObjects', - schema: schema.object({ - maxImportPayloadBytes: schema.byteSize({ defaultValue: 26_214_400 }), - maxImportExportSize: schema.number({ defaultValue: 10_000 }), - }), + schema: soSchema, }; export class SavedObjectConfig { diff --git a/src/core/server/test_utils.ts b/src/core/server/test_utils.ts index 656d2bfe60fac..cf18defb0a960 100644 --- a/src/core/server/test_utils.ts +++ b/src/core/server/test_utils.ts @@ -9,3 +9,4 @@ export { createHttpServer } from './http/test_utils'; export { ServiceStatusLevelSnapshotSerializer } from './status/test_utils'; export { setupServer } from './saved_objects/routes/test_utils'; +export { getDeprecationsFor, getDeprecationsForGlobalSettings } from './config/test_utils'; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 88f1c0226cb37..2a851eeccdce6 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -80,6 +80,7 @@ export const DataGrid: FC = memo( baseline, chartsVisible, chartsButtonVisible, + ccsWarning, columnsWithCharts, dataTestSubj, errorMessage, @@ -291,6 +292,24 @@ export const DataGrid: FC = memo( )} + {ccsWarning && ( +
+ +

+ {i18n.translate('xpack.ml.dataGrid.CcsWarningCalloutBody', { + defaultMessage: + 'There was an issue retrieving data for the index pattern. Source preview in combination with cross-cluster search is only supported for versions 7.10 and above. You may still configure and create the transform.', + })} +

+
+ +
+ )}
void; rowCount: number; rowCountRelation: RowCountRelation; + setCcsWarning: Dispatch>; setColumnCharts: Dispatch>; setErrorMessage: Dispatch>; setNoDataMessage: Dispatch>; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx index e62f2eb2f003b..633c3d9aab002 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx @@ -36,6 +36,7 @@ export const useDataGrid = ( ): UseDataGridReturnType => { const defaultPagination: IndexPagination = { pageIndex: 0, pageSize: defaultPageSize }; + const [ccsWarning, setCcsWarning] = useState(false); const [noDataMessage, setNoDataMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); @@ -152,6 +153,7 @@ export const useDataGrid = ( }, [chartsVisible, rowCount, rowCountRelation]); return { + ccsWarning, chartsVisible, chartsButtonVisible: true, columnsWithCharts, @@ -166,6 +168,7 @@ export const useDataGrid = ( rowCount, rowCountRelation, setColumnCharts, + setCcsWarning, setErrorMessage, setNoDataMessage, setPagination, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 0f1b50a7b9316..505673f440ef2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -160,11 +160,24 @@ export interface RocCurveItem { tpr: number; } +interface EvalClass { + class_name: string; + value: number; +} + export interface ClassificationEvaluateResponse { classification: { multiclass_confusion_matrix?: { confusion_matrix: ConfusionMatrix[]; }; + recall?: { + classes: EvalClass[]; + avg_recall: number; + }; + accuracy?: { + classes: EvalClass[]; + overall_accuracy: number; + }; auc_roc?: { curve?: RocCurveItem[]; value: number; @@ -434,6 +447,8 @@ export enum REGRESSION_STATS { interface EvaluateMetrics { classification: { + accuracy?: object; + recall?: object; multiclass_confusion_matrix?: object; auc_roc?: { include_curve: boolean; class_name: string }; }; @@ -486,6 +501,8 @@ export const loadEvalData = async ({ const metrics: EvaluateMetrics = { classification: { + accuracy: {}, + recall: {}, ...(includeMulticlassConfusionMatrix ? { multiclass_confusion_matrix: {} } : {}), ...(rocCurveClassName !== undefined ? { auc_roc: { include_curve: true, class_name: rocCurveClassName } } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index e848f209516f4..3795af32f6638 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -34,6 +34,7 @@ import { DataFrameTaskStateType } from '../../../analytics_management/components import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; +import { EvaluateStat } from './evaluate_stat'; import { getRocCurveChartVegaLiteSpec } from './get_roc_curve_chart_vega_lite_spec'; @@ -112,10 +113,12 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se const isTraining = isTrainingFilter(searchQuery, resultsField); const { + avgRecall, confusionMatrixData, docsCount, error: errorConfusionMatrix, isLoading: isLoadingConfusionMatrix, + overallAccuracy, } = useConfusionMatrix(jobConfig, searchQuery); useEffect(() => { @@ -368,8 +371,52 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se )} ) : null} + {/* Accuracy and Recall */} + + + + + + + + + {/* AUC ROC Chart */} - + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx new file mode 100644 index 0000000000000..4bb8415d833f6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_stat.tsx @@ -0,0 +1,44 @@ +/* + * 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, { FC } from 'react'; +import { EuiStat, EuiIconTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EMPTY_STAT } from '../../../../common/analytics'; + +interface Props { + isLoading: boolean; + title: number | null; + description: string; + dataTestSubj: string; + tooltipContent: string; +} + +export const EvaluateStat: FC = ({ + isLoading, + title, + description, + dataTestSubj, + tooltipContent, +}) => ( + + + + + + + + +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts index be44a8e36ed00..df48d2c5ab44f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -30,6 +30,8 @@ export const useConfusionMatrix = ( searchQuery: ResultsSearchQuery ) => { const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [overallAccuracy, setOverallAccuracy] = useState(null); + const [avgRecall, setAvgRecall] = useState(null); const [isLoading, setIsLoading] = useState(false); const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); @@ -77,6 +79,8 @@ export const useConfusionMatrix = ( evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; setError(null); setConfusionMatrixData(confusionMatrix || []); + setAvgRecall(evalData.eval?.classification?.recall?.avg_recall || null); + setOverallAccuracy(evalData.eval?.classification?.accuracy?.overall_accuracy || null); setIsLoading(false); } else { setIsLoading(false); @@ -94,5 +98,5 @@ export const useConfusionMatrix = ( loadConfusionMatrixData(); }, [JSON.stringify([jobConfig, searchQuery])]); - return { confusionMatrixData, docsCount, error, isLoading }; + return { avgRecall, confusionMatrixData, docsCount, error, isLoading, overallAccuracy }; }; diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index a9455877be429..3d5e1783f8c62 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -136,9 +136,20 @@ const apiFactory = () => ({ return Promise.resolve([]); }, async esSearch(payload: any): Promise { + const hits = []; + + // simulate a cross cluster search result + // against a cluster that doesn't support fields + if (payload.index.includes(':')) { + hits.push({ + _id: 'the-doc', + _index: 'the-index', + }); + } + return Promise.resolve({ hits: { - hits: [], + hits, total: { value: 0, relation: 'eq', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx index bd361afac2d8d..3e0a247106f2a 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx @@ -7,7 +7,8 @@ import React, { FC } from 'react'; -import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { render, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { CoreSetup } from 'src/core/public'; @@ -49,6 +50,7 @@ describe('Transform: useIndexData()', () => { const wrapper: FC = ({ children }) => ( {children} ); + const { result, waitForNextUpdate } = renderHook( () => useIndexData( @@ -62,6 +64,7 @@ describe('Transform: useIndexData()', () => { ), { wrapper } ); + const IndexObj: UseIndexDataReturnType = result.current; await waitForNextUpdate(); @@ -73,7 +76,7 @@ describe('Transform: useIndexData()', () => { }); describe('Transform: with useIndexData()', () => { - test('Minimal initialization', async () => { + test('Minimal initialization, no cross cluster search warning.', async () => { // Arrange const indexPattern = { title: 'the-index-pattern-title', @@ -97,7 +100,47 @@ describe('Transform: with useIndexData()', () => { return ; }; - const { getByText } = render( + + const { queryByText } = render( + + + + ); + + // Act + // Assert + await waitFor(() => { + expect(queryByText('the-index-preview-title')).toBeInTheDocument(); + expect(queryByText('Cross-cluster search returned no fields data.')).not.toBeInTheDocument(); + }); + }); + + test('Cross-cluster search warning', async () => { + // Arrange + const indexPattern = { + title: 'remote:the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern']; + + const mlSharedImports = await getMlSharedImports(); + + const Wrapper = () => { + const { + ml: { DataGrid }, + } = useAppDependencies(); + const props = { + ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + copyToClipboard: 'the-copy-to-clipboard-code', + copyToClipboardDescription: 'the-copy-to-clipboard-description', + dataTestSubj: 'the-data-test-subj', + title: 'the-index-preview-title', + toastNotifications: {} as CoreSetup['notifications']['toasts'], + }; + + return ; + }; + + const { queryByText } = render( @@ -105,6 +148,9 @@ describe('Transform: with useIndexData()', () => { // Act // Assert - expect(getByText('the-index-preview-title')).toBeInTheDocument(); + await waitFor(() => { + expect(queryByText('the-index-preview-title')).toBeInTheDocument(); + expect(queryByText('Cross-cluster search returned no fields data.')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 36ba07afd69cd..f97693b8c038a 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -87,6 +87,7 @@ export const useIndexData = ( pagination, resetPagination, setColumnCharts, + setCcsWarning, setErrorMessage, setRowCount, setRowCountRelation, @@ -134,8 +135,12 @@ export const useIndexData = ( return; } + const isCrossClusterSearch = indexPattern.title.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + setCcsWarning(isCrossClusterSearch && isMissingFields); setRowCount(typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total.value); setRowCountRelation( typeof resp.hits.total === 'number' diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 51d3a0bd02d50..1e3fa2026061b 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, wait } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { PIVOT_SUPPORTED_AGGS } from '../../../../../../common/types/pivot_aggs'; @@ -77,7 +77,7 @@ describe('Transform: ', () => { }, }; - const { getByText } = render( + const { queryByText } = render( @@ -85,8 +85,9 @@ describe('Transform: ', () => { // Act // Assert - expect(getByText('Group by')).toBeInTheDocument(); - expect(getByText('Aggregations')).toBeInTheDocument(); - await wait(); + await waitFor(() => { + expect(queryByText('Group by')).toBeInTheDocument(); + expect(queryByText('Aggregations')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 008fc8237c129..e9212bf633a79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from 'react-dom/test-utils'; import { EsIndexActionConnector } from '../types'; import IndexActionConnectorFields from './es_index_connector'; +import { EuiComboBox, EuiSwitch, EuiSwitchEvent, EuiSelect } from '@elastic/eui'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/index_controls', () => ({ @@ -19,83 +20,263 @@ jest.mock('../../../../common/index_controls', () => ({ getIndexPatterns: jest.fn(), })); +const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); +getIndexPatterns.mockResolvedValueOnce([ + { + id: 'indexPattern1', + attributes: { + title: 'indexPattern1', + }, + }, + { + id: 'indexPattern2', + attributes: { + title: 'indexPattern2', + }, + }, +]); + +const { getFields } = jest.requireMock('../../../../common/index_controls'); + +async function setup(props: any) { + const wrapper = mountWithIntl(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + return wrapper; +} + +function setupGetFieldsResponse(getFieldsWithDateMapping: boolean) { + getFields.mockResolvedValueOnce([ + { + type: getFieldsWithDateMapping ? 'date' : 'keyword', + name: 'test1', + }, + { + type: 'text', + name: 'test2', + }, + ]); +} describe('IndexActionConnectorFields renders', () => { - test('all connector fields is rendered', async () => { - const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); - getIndexPatterns.mockResolvedValueOnce([ - { - id: 'indexPattern1', - attributes: { - title: 'indexPattern1', - }, - }, - { - id: 'indexPattern2', - attributes: { - title: 'indexPattern2', - }, - }, - ]); - const { getFields } = jest.requireMock('../../../../common/index_controls'); - getFields.mockResolvedValueOnce([ - { - type: 'date', - name: 'test1', - }, - { - type: 'text', - name: 'test2', - }, - ]); - - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test', - refresh: false, - executionTimeField: 'test1', - }, - } as EsIndexActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - readOnly={false} - /> - ); + test('renders correctly when creating connector', async () => { + const props = { + action: { + actionTypeId: '.index', + config: {}, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').exists()).toBeTruthy(); + + // time field switch shouldn't show up initially + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); + + const indexComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="connectorIndexesComboBox"]'); + + // time field switch should show up if index has date type field mapping + setupGetFieldsResponse(true); await act(async () => { + indexComboBox.prop('onChange')!([{ label: 'selection' }]); await nextTick(); wrapper.update(); }); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); - expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); + // time field switch should go away if index does not has date type field mapping + setupGetFieldsResponse(false); + await act(async () => { + indexComboBox.prop('onChange')!([{ label: 'selection' }]); + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); + + // time field dropdown should show up if index has date type field mapping and time switch is clicked + setupGetFieldsResponse(true); + await act(async () => { + indexComboBox.prop('onChange')!([{ label: 'selection' }]); + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeTruthy(); + const timeFieldSwitch = wrapper + .find(EuiSwitch) + .filter('[data-test-subj="hasTimeFieldCheckbox"]'); + await act(async () => { + timeFieldSwitch.prop('onChange')!(({ + target: { checked: true }, + } as unknown) as EuiSwitchEvent); + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeTruthy(); + }); + + test('renders correctly when editing connector - no date type field mapping', async () => { + const indexName = 'index-no-date-fields'; + const props = { + action: { + name: 'Index Connector for Index With No Date Type', + actionTypeId: '.index', + config: { + index: indexName, + refresh: false, + }, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + }; + setupGetFieldsResponse(false); + const wrapper = await setup(props); + + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').exists()).toBeTruthy(); + + // time related fields shouldn't show up + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); + + const indexComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="connectorIndexesComboBox"]'); + expect(indexComboBox.prop('selectedOptions')).toEqual([{ label: indexName, value: indexName }]); + + const refreshSwitch = wrapper.find(EuiSwitch).filter('[data-test-subj="indexRefreshCheckbox"]'); + expect(refreshSwitch.prop('checked')).toEqual(false); + }); + + test('renders correctly when editing connector - refresh set to true', async () => { + const indexName = 'index-no-date-fields'; + const props = { + action: { + name: 'Index Connector for Index With No Date Type', + actionTypeId: '.index', + config: { + index: indexName, + refresh: true, + }, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + }; + setupGetFieldsResponse(false); + const wrapper = await setup(props); + + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); + + const indexComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="connectorIndexesComboBox"]'); + expect(indexComboBox.prop('selectedOptions')).toEqual([{ label: indexName, value: indexName }]); + + const refreshSwitch = wrapper.find(EuiSwitch).filter('[data-test-subj="indexRefreshCheckbox"]'); + expect(refreshSwitch.prop('checked')).toEqual(true); + }); + + test('renders correctly when editing connector - with date type field mapping but no time field selected', async () => { + const indexName = 'index-no-date-fields'; + const props = { + action: { + name: 'Index Connector for Index With No Date Type', + actionTypeId: '.index', + config: { + index: indexName, + refresh: false, + }, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + }; + setupGetFieldsResponse(true); + const wrapper = await setup(props); + + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeFalsy(); + + const indexComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="connectorIndexesComboBox"]'); + expect(indexComboBox.prop('selectedOptions')).toEqual([{ label: indexName, value: indexName }]); + + const refreshSwitch = wrapper.find(EuiSwitch).filter('[data-test-subj="indexRefreshCheckbox"]'); + expect(refreshSwitch.prop('checked')).toEqual(false); + + const timeFieldSwitch = wrapper + .find(EuiSwitch) + .filter('[data-test-subj="hasTimeFieldCheckbox"]'); + expect(timeFieldSwitch.prop('checked')).toEqual(false); + }); + + test('renders correctly when editing connector - with date type field mapping and selected time field', async () => { + const indexName = 'index-no-date-fields'; + const props = { + action: { + name: 'Index Connector for Index With No Date Type', + actionTypeId: '.index', + config: { + index: indexName, + refresh: false, + executionTimeField: 'test1', + }, + secrets: {}, + } as EsIndexActionConnector, + editActionConfig: () => {}, + editActionSecrets: () => {}, + errors: { index: [] }, + readOnly: false, + }; + setupGetFieldsResponse(true); + const wrapper = await setup(props); - const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); - expect(indexSearchBoxValue.first().props().value).toEqual(''); + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="hasTimeFieldCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="executionTimeFieldSelect"]').exists()).toBeTruthy(); - const indexComboBox = wrapper.find('#indexConnectorSelectSearchBox'); - indexComboBox.first().simulate('click'); - const event = { target: { value: 'indexPattern1' } }; - indexComboBox.find('input').first().simulate('change', event); + const indexComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="connectorIndexesComboBox"]'); + expect(indexComboBox.prop('selectedOptions')).toEqual([{ label: indexName, value: indexName }]); - const indexSearchBoxValueBeforeEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueBeforeEnterData.first().props().value).toEqual('indexPattern1'); + const refreshSwitch = wrapper.find(EuiSwitch).filter('[data-test-subj="indexRefreshCheckbox"]'); + expect(refreshSwitch.prop('checked')).toEqual(false); - const indexComboBoxClear = wrapper.find('[data-test-subj="comboBoxClearButton"]'); - indexComboBoxClear.first().simulate('click'); + const timeFieldSwitch = wrapper + .find(EuiSwitch) + .filter('[data-test-subj="hasTimeFieldCheckbox"]'); + expect(timeFieldSwitch.prop('checked')).toEqual(true); - const indexSearchBoxValueAfterEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueAfterEnterData.first().props().value).toEqual('indexPattern1'); + const timeFieldSelect = wrapper + .find(EuiSelect) + .filter('[data-test-subj="executionTimeFieldSelect"]'); + expect(timeFieldSelect.prop('value')).toEqual('test1'); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index cd3a03ecce15c..72af41277c29c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -30,29 +30,45 @@ import { } from '../../../../common/index_controls'; import { useKibana } from '../../../../common/lib/kibana'; +interface TimeFieldOptions { + value: string; + text: string; +} + const IndexActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps > = ({ action, editActionConfig, errors, readOnly }) => { const { http, docLinks } = useKibana().services; const { index, refresh, executionTimeField } = action.config; - const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( + const [showTimeFieldCheckbox, setShowTimeFieldCheckboxState] = useState( + executionTimeField != null + ); + const [hasTimeFieldCheckbox, setHasTimeFieldCheckboxState] = useState( executionTimeField != null ); const [indexPatterns, setIndexPatterns] = useState([]); const [indexOptions, setIndexOptions] = useState([]); - const [timeFieldOptions, setTimeFieldOptions] = useState>([ - firstFieldOption, - ]); + const [timeFieldOptions, setTimeFieldOptions] = useState([]); const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const setTimeFields = (fields: TimeFieldOptions[]) => { + if (fields.length > 0) { + setShowTimeFieldCheckboxState(true); + setTimeFieldOptions([firstFieldOption, ...fields]); + } else { + setHasTimeFieldCheckboxState(false); + setShowTimeFieldCheckboxState(false); + setTimeFieldOptions([]); + } + }; + useEffect(() => { const indexPatternsFunction = async () => { setIndexPatterns(await getIndexPatterns()); if (index) { const currentEsFields = await getFields(http!, [index]); - const timeFields = getTimeFieldOptions(currentEsFields as any); - setTimeFieldOptions([firstFieldOption, ...timeFields]); + setTimeFields(getTimeFieldOptions(currentEsFields as any)); } }; indexPatternsFunction(); @@ -123,13 +139,11 @@ const IndexActionConnectorFields: React.FunctionComponent< // reset time field and expression fields if indices are deleted if (indices.length === 0) { - setTimeFieldOptions([]); + setTimeFields([]); return; } const currentEsFields = await getFields(http!, indices); - const timeFields = getTimeFieldOptions(currentEsFields as any); - - setTimeFieldOptions([firstFieldOption, ...timeFields]); + setTimeFields(getTimeFieldOptions(currentEsFields as any)); }} onSearchChange={async (search) => { setIsIndiciesLoading(true); @@ -172,38 +186,40 @@ const IndexActionConnectorFields: React.FunctionComponent< } /> - { - setTimeFieldCheckboxState(!hasTimeFieldCheckbox); - // if changing from checked to not checked (hasTimeField === true), - // set time field to null - if (hasTimeFieldCheckbox) { - editActionConfig('executionTimeField', null); + {showTimeFieldCheckbox && ( + { + setHasTimeFieldCheckboxState(!hasTimeFieldCheckbox); + // if changing from checked to not checked (hasTimeField === true), + // set time field to null + if (hasTimeFieldCheckbox) { + editActionConfig('executionTimeField', null); + } + }} + label={ + <> + + + } - }} - label={ - <> - - - - } - /> - {hasTimeFieldCheckbox ? ( + /> + )} + {hasTimeFieldCheckbox && ( <> - ) : null} + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx index 3baf4e33fb68d..44c950a500040 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx @@ -59,8 +59,13 @@ describe('health check', () => { it('renders children if keys are enabled', async () => { useKibanaMock().services.http.get = jest.fn().mockResolvedValue({ - isSufficientlySecure: true, - hasPermanentEncryptionKey: true, + is_sufficiently_secure: true, + has_permanent_encryption_key: true, + alerting_framework_heath: { + decryption_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + execution_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + read_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, isAlertsAvailable: true, }); const { queryByText } = render( @@ -78,8 +83,13 @@ describe('health check', () => { test('renders warning if TLS is required', async () => { useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ - isSufficientlySecure: false, - hasPermanentEncryptionKey: true, + is_sufficiently_secure: false, + has_permanent_encryption_key: true, + alerting_framework_heath: { + decryption_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + execution_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + read_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, isAlertsAvailable: true, })); const { queryAllByText } = render( @@ -110,8 +120,13 @@ describe('health check', () => { test('renders warning if encryption key is ephemeral', async () => { useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ - isSufficientlySecure: true, - hasPermanentEncryptionKey: false, + is_sufficiently_secure: true, + has_permanent_encryption_key: false, + alerting_framework_heath: { + decryption_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + execution_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + read_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, isAlertsAvailable: true, })); const { queryByText, queryByRole } = render( @@ -139,8 +154,13 @@ describe('health check', () => { test('renders warning if encryption key is ephemeral and keys are disabled', async () => { useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ - isSufficientlySecure: false, - hasPermanentEncryptionKey: false, + is_sufficiently_secure: false, + has_permanent_encryption_key: false, + alerting_framework_heath: { + decryption_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + execution_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + read_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, isAlertsAvailable: true, })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index 208fd5ec66f1d..d75ab102a8e0c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -15,12 +15,12 @@ import { i18n } from '@kbn/i18n'; import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { DocLinksStart } from 'kibana/public'; -import { alertingFrameworkHealth } from '../lib/alert_api'; import './health_check.scss'; import { useHealthContext } from '../context/health_context'; import { useKibana } from '../../common/lib/kibana'; import { CenterJustifiedSpinner } from './center_justified_spinner'; import { triggersActionsUiHealth } from '../../common/lib/health_api'; +import { alertingFrameworkHealth } from '../lib/alert_api'; interface Props { inFlyout?: boolean; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 8ac1fbaec403b..cc04b8e7871cd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -7,7 +7,10 @@ import { i18n } from '@kbn/i18n'; -export { LEGACY_BASE_ALERT_API_PATH } from '../../../../alerting/common'; +export { + BASE_ALERTING_API_PATH, + INTERNAL_BASE_ALERTING_API_PATH, +} from '../../../../alerting/common'; export { BASE_ACTION_API_PATH } from '../../../../actions/common'; export type Section = 'connectors' | 'rules'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts deleted file mode 100644 index d112e7ac284ae..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -/* - * 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 { Alert, AlertType, AlertUpdates } from '../../types'; -import { httpServiceMock } from '../../../../../../src/core/public/mocks'; -import { - createAlert, - deleteAlerts, - disableAlerts, - enableAlerts, - disableAlert, - enableAlert, - loadAlert, - loadAlertAggregations, - loadAlerts, - loadAlertState, - loadAlertTypes, - muteAlerts, - unmuteAlerts, - muteAlert, - unmuteAlert, - updateAlert, - muteAlertInstance, - unmuteAlertInstance, - alertingFrameworkHealth, - mapFiltersToKql, -} from './alert_api'; -import uuid from 'uuid'; -import { AlertNotifyWhenType, ALERTS_FEATURE_ID } from '../../../../alerting/common'; - -const http = httpServiceMock.createStartContract(); - -beforeEach(() => jest.resetAllMocks()); - -describe('loadAlertTypes', () => { - test('should call get alert types API', async () => { - const resolvedValue: AlertType[] = [ - { - id: 'test', - name: 'Test', - actionVariables: { - context: [{ name: 'var1', description: 'val1' }], - state: [{ name: 'var2', description: 'val2' }], - params: [{ name: 'var3', description: 'val3' }], - }, - producer: ALERTS_FEATURE_ID, - actionGroups: [{ id: 'default', name: 'Default' }], - recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, - defaultActionGroupId: 'default', - authorizedConsumers: {}, - minimumLicenseRequired: 'basic', - enabledInLicense: true, - }, - ]; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertTypes({ http }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/list_alert_types", - ] - `); - }); -}); - -describe('loadAlert', () => { - test('should call get API with base parameters', async () => { - const alertId = uuid.v4(); - const resolvedValue = { - id: alertId, - name: 'name', - tags: [], - enabled: true, - alertTypeId: '.noop', - schedule: { interval: '1s' }, - actions: [], - params: {}, - createdBy: null, - updatedBy: null, - throttle: null, - muteAll: false, - mutedInstanceIds: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - expect(await loadAlert({ http, alertId })).toEqual(resolvedValue); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}`); - }); -}); - -describe('loadAlertState', () => { - test('should call get API with base parameters', async () => { - const alertId = uuid.v4(); - const resolvedValue = { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: {}, - second_instance: {}, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); - - test('should parse AlertInstances', async () => { - const alertId = uuid.v4(); - const resolvedValue = { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: '2020-02-09T23:15:41.941Z', - }, - }, - }, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - expect(await loadAlertState({ http, alertId })).toEqual({ - ...resolvedValue, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date('2020-02-09T23:15:41.941Z'), - }, - }, - }, - }, - }); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); - - test('should handle empty response from api', async () => { - const alertId = uuid.v4(); - http.get.mockResolvedValueOnce(''); - - expect(await loadAlertState({ http, alertId })).toEqual({}); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); -}); - -describe('loadAlerts', () => { - test('should call find API with base parameters', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ http, page: { index: 0, size: 10 } }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "page": 1, - "per_page": 10, - "search": undefined, - "search_fields": undefined, - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); - - test('should call find API with searchText', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "page": 1, - "per_page": 10, - "search": "apples", - "search_fields": "[\\"name\\",\\"tags\\"]", - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); - - test('should call find API with actionTypesFilter', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ - http, - searchText: 'foo', - page: { index: 0, size: 10 }, - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "page": 1, - "per_page": 10, - "search": "foo", - "search_fields": "[\\"name\\",\\"tags\\"]", - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); - - test('should call find API with typesFilter', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ - http, - typesFilter: ['foo', 'bar'], - page: { index: 0, size: 10 }, - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": undefined, - "search_fields": undefined, - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); - - test('should call find API with actionTypesFilter and typesFilter', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ - http, - searchText: 'baz', - typesFilter: ['foo', 'bar'], - page: { index: 0, size: 10 }, - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": "baz", - "search_fields": "[\\"name\\",\\"tags\\"]", - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); - - test('should call find API with searchText and tagsFilter and typesFilter', async () => { - const resolvedValue = { - page: 1, - perPage: 10, - total: 0, - data: [], - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlerts({ - http, - searchText: 'apples, foo, baz', - typesFilter: ['foo', 'bar'], - page: { index: 0, size: 10 }, - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": "apples, foo, baz", - "search_fields": "[\\"name\\",\\"tags\\"]", - "sort_field": "name", - "sort_order": "asc", - }, - }, - ] - `); - }); -}); - -describe('loadAlertAggregations', () => { - test('should call aggregate API with base parameters', async () => { - const resolvedValue = { - alertExecutionStatus: { - ok: 4, - active: 2, - error: 1, - pending: 1, - unknown: 0, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertAggregations({ http }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_aggregate", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "search": undefined, - "search_fields": undefined, - }, - }, - ] - `); - }); - - test('should call aggregate API with searchText', async () => { - const resolvedValue = { - alertExecutionStatus: { - ok: 4, - active: 2, - error: 1, - pending: 1, - unknown: 0, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertAggregations({ http, searchText: 'apples' }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_aggregate", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "search": "apples", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); - }); - - test('should call aggregate API with actionTypesFilter', async () => { - const resolvedValue = { - alertExecutionStatus: { - ok: 4, - active: 2, - error: 1, - pending: 1, - unknown: 0, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertAggregations({ - http, - searchText: 'foo', - actionTypesFilter: ['action', 'type'], - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_aggregate", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })", - "search": "foo", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); - }); - - test('should call aggregate API with typesFilter', async () => { - const resolvedValue = { - alertExecutionStatus: { - ok: 4, - active: 2, - error: 1, - pending: 1, - unknown: 0, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertAggregations({ - http, - typesFilter: ['foo', 'bar'], - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_aggregate", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "search": undefined, - "search_fields": undefined, - }, - }, - ] - `); - }); - - test('should call aggregate API with actionTypesFilter and typesFilter', async () => { - const resolvedValue = { - alertExecutionStatus: { - ok: 4, - active: 2, - error: 1, - pending: 1, - unknown: 0, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - const result = await loadAlertAggregations({ - http, - searchText: 'baz', - actionTypesFilter: ['action', 'type'], - typesFilter: ['foo', 'bar'], - }); - expect(result).toEqual(resolvedValue); - expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/_aggregate", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar) and (alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })", - "search": "baz", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); - }); -}); - -describe('deleteAlerts', () => { - test('should call delete API for each alert', async () => { - const ids = ['1', '2', '3']; - const result = await deleteAlerts({ http, ids }); - expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); - expect(http.delete.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1", - ], - Array [ - "/api/alerts/alert/2", - ], - Array [ - "/api/alerts/alert/3", - ], - ] - `); - }); -}); - -describe('createAlert', () => { - test('should call create alert API', async () => { - const alertToCreate: AlertUpdates = { - name: 'test', - consumer: 'alerts', - tags: ['foo'], - enabled: true, - alertTypeId: 'test', - schedule: { - interval: '1m', - }, - actions: [], - params: {}, - throttle: null, - notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, - createdAt: new Date('1970-01-01T00:00:00.000Z'), - updatedAt: new Date('1970-01-01T00:00:00.000Z'), - apiKeyOwner: null, - createdBy: null, - updatedBy: null, - muteAll: false, - mutedInstanceIds: [], - }; - const resolvedValue = { - ...alertToCreate, - id: '123', - createdBy: null, - updatedBy: null, - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - }; - http.post.mockResolvedValueOnce(resolvedValue); - - const result = await createAlert({ http, alert: alertToCreate }); - expect(result).toEqual(resolvedValue); - expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/alert", - Object { - "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerts\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"notifyWhen\\":\\"onActionGroupChange\\",\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKeyOwner\\":null,\\"createdBy\\":null,\\"updatedBy\\":null,\\"muteAll\\":false,\\"mutedInstanceIds\\":[]}", - }, - ] - `); - }); -}); - -describe('updateAlert', () => { - test('should call alert update API', async () => { - const alertToUpdate = { - throttle: '1m', - consumer: 'alerts', - name: 'test', - tags: ['foo'], - schedule: { - interval: '1m', - }, - params: {}, - actions: [], - createdAt: new Date('1970-01-01T00:00:00.000Z'), - updatedAt: new Date('1970-01-01T00:00:00.000Z'), - apiKey: null, - apiKeyOwner: null, - notifyWhen: 'onThrottleInterval' as AlertNotifyWhenType, - }; - const resolvedValue: Alert = { - ...alertToUpdate, - id: '123', - enabled: true, - alertTypeId: 'test', - createdBy: null, - updatedBy: null, - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - }; - http.put.mockResolvedValueOnce(resolvedValue); - - const result = await updateAlert({ http, id: '123', alert: alertToUpdate }); - expect(result).toEqual(resolvedValue); - expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alerts/alert/123", - Object { - "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"notifyWhen\\":\\"onThrottleInterval\\"}", - }, - ] - `); - }); -}); - -describe('enableAlert', () => { - test('should call enable alert API', async () => { - const result = await enableAlert({ http, id: '1' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_enable", - ], - ] - `); - }); -}); - -describe('disableAlert', () => { - test('should call disable alert API', async () => { - const result = await disableAlert({ http, id: '1' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_disable", - ], - ] - `); - }); -}); - -describe('muteAlertInstance', () => { - test('should call mute instance alert API', async () => { - const result = await muteAlertInstance({ http, id: '1', instanceId: '123' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/alert_instance/123/_mute", - ], - ] - `); - }); -}); - -describe('unmuteAlertInstance', () => { - test('should call mute instance alert API', async () => { - const result = await unmuteAlertInstance({ http, id: '1', instanceId: '123' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/alert_instance/123/_unmute", - ], - ] - `); - }); -}); - -describe('muteAlert', () => { - test('should call mute alert API', async () => { - const result = await muteAlert({ http, id: '1' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_mute_all", - ], - ] - `); - }); -}); - -describe('unmuteAlert', () => { - test('should call unmute alert API', async () => { - const result = await unmuteAlert({ http, id: '1' }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_unmute_all", - ], - ] - `); - }); -}); - -describe('enableAlerts', () => { - test('should call enable alert API per alert', async () => { - const ids = ['1', '2', '3']; - const result = await enableAlerts({ http, ids }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_enable", - ], - Array [ - "/api/alerts/alert/2/_enable", - ], - Array [ - "/api/alerts/alert/3/_enable", - ], - ] - `); - }); -}); - -describe('disableAlerts', () => { - test('should call disable alert API per alert', async () => { - const ids = ['1', '2', '3']; - const result = await disableAlerts({ http, ids }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_disable", - ], - Array [ - "/api/alerts/alert/2/_disable", - ], - Array [ - "/api/alerts/alert/3/_disable", - ], - ] - `); - }); -}); - -describe('muteAlerts', () => { - test('should call mute alert API per alert', async () => { - const ids = ['1', '2', '3']; - const result = await muteAlerts({ http, ids }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_mute_all", - ], - Array [ - "/api/alerts/alert/2/_mute_all", - ], - Array [ - "/api/alerts/alert/3/_mute_all", - ], - ] - `); - }); -}); - -describe('unmuteAlerts', () => { - test('should call unmute alert API per alert', async () => { - const ids = ['1', '2', '3']; - const result = await unmuteAlerts({ http, ids }); - expect(result).toEqual(undefined); - expect(http.post.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/alert/1/_unmute_all", - ], - Array [ - "/api/alerts/alert/2/_unmute_all", - ], - Array [ - "/api/alerts/alert/3/_unmute_all", - ], - ] - `); - }); -}); - -describe('alertingFrameworkHealth', () => { - test('should call alertingFrameworkHealth API', async () => { - const result = await alertingFrameworkHealth({ http }); - expect(result).toEqual(undefined); - expect(http.get.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/api/alerts/_health", - ], - ] - `); - }); -}); - -describe('mapFiltersToKql', () => { - test('should handle no filters', () => { - expect(mapFiltersToKql({})).toEqual([]); - }); - - test('should handle typesFilter', () => { - expect( - mapFiltersToKql({ - typesFilter: ['type', 'filter'], - }) - ).toEqual(['alert.attributes.alertTypeId:(type or filter)']); - }); - - test('should handle actionTypesFilter', () => { - expect( - mapFiltersToKql({ - actionTypesFilter: ['action', 'types', 'filter'], - }) - ).toEqual([ - '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', - ]); - }); - - test('should handle alertStatusesFilter', () => { - expect( - mapFiltersToKql({ - alertStatusesFilter: ['alert', 'statuses', 'filter'], - }) - ).toEqual(['alert.attributes.executionStatus.status:(alert or statuses or filter)']); - }); - - test('should handle typesFilter and actionTypesFilter', () => { - expect( - mapFiltersToKql({ - typesFilter: ['type', 'filter'], - actionTypesFilter: ['action', 'types', 'filter'], - }) - ).toEqual([ - 'alert.attributes.alertTypeId:(type or filter)', - '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', - ]); - }); - - test('should handle typesFilter, actionTypesFilter and alertStatusesFilter', () => { - expect( - mapFiltersToKql({ - typesFilter: ['type', 'filter'], - actionTypesFilter: ['action', 'types', 'filter'], - alertStatusesFilter: ['alert', 'statuses', 'filter'], - }) - ).toEqual([ - 'alert.attributes.alertTypeId:(type or filter)', - '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', - 'alert.attributes.executionStatus.status:(alert or statuses or filter)', - ]); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts deleted file mode 100644 index 80ff415582191..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ /dev/null @@ -1,296 +0,0 @@ -/* - * 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 { HttpSetup } from 'kibana/public'; -import { Errors, identity } from 'io-ts'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { pick } from 'lodash'; -import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerting/common'; -import { LEGACY_BASE_ALERT_API_PATH } from '../constants'; -import { - Alert, - AlertAggregations, - AlertType, - AlertUpdates, - AlertTaskState, - AlertInstanceSummary, - Pagination, - Sorting, -} from '../../types'; - -export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/list_alert_types`); -} - -export async function loadAlert({ - http, - alertId, -}: { - http: HttpSetup; - alertId: string; -}): Promise { - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${alertId}`); -} - -type EmptyHttpResponse = ''; -export async function loadAlertState({ - http, - alertId, -}: { - http: HttpSetup; - alertId: string; -}): Promise { - return await http - .get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${alertId}/state`) - .then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {})) - .then((state: AlertTaskState) => { - return pipe( - alertStateSchema.decode(state), - fold((e: Errors) => { - throw new Error(`Alert "${alertId}" has invalid state`); - }, identity) - ); - }); -} - -export async function loadAlertInstanceSummary({ - http, - alertId, -}: { - http: HttpSetup; - alertId: string; -}): Promise { - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/alert/${alertId}/_instance_summary`); -} - -export const mapFiltersToKql = ({ - typesFilter, - actionTypesFilter, - alertStatusesFilter, -}: { - typesFilter?: string[]; - actionTypesFilter?: string[]; - alertStatusesFilter?: string[]; -}): string[] => { - const filters = []; - if (typesFilter && typesFilter.length) { - filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`); - } - if (actionTypesFilter && actionTypesFilter.length) { - filters.push( - [ - '(', - actionTypesFilter - .map((id) => `alert.attributes.actions:{ actionTypeId:${id} }`) - .join(' OR '), - ')', - ].join('') - ); - } - if (alertStatusesFilter && alertStatusesFilter.length) { - filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`); - } - return filters; -}; - -export async function loadAlerts({ - http, - page, - searchText, - typesFilter, - actionTypesFilter, - alertStatusesFilter, - sort = { field: 'name', direction: 'asc' }, -}: { - http: HttpSetup; - page: Pagination; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - alertStatusesFilter?: string[]; - sort?: Sorting; -}): Promise<{ - page: number; - perPage: number; - total: number; - data: Alert[]; -}> { - const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter }); - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/_find`, { - query: { - page: page.index + 1, - per_page: page.size, - search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, - search: searchText, - filter: filters.length ? filters.join(' and ') : undefined, - default_search_operator: 'AND', - sort_field: sort.field, - sort_order: sort.direction, - }, - }); -} - -export async function loadAlertAggregations({ - http, - searchText, - typesFilter, - actionTypesFilter, - alertStatusesFilter, -}: { - http: HttpSetup; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - alertStatusesFilter?: string[]; -}): Promise { - const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter }); - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/_aggregate`, { - query: { - search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, - search: searchText, - filter: filters.length ? filters.join(' and ') : undefined, - default_search_operator: 'AND', - }, - }); -} - -export async function deleteAlerts({ - ids, - http, -}: { - ids: string[]; - http: HttpSetup; -}): Promise<{ successes: string[]; errors: string[] }> { - const successes: string[] = []; - const errors: string[] = []; - await Promise.all(ids.map((id) => http.delete(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}`))).then( - function (fulfilled) { - successes.push(...fulfilled); - }, - function (rejected) { - errors.push(...rejected); - } - ); - return { successes, errors }; -} - -export async function createAlert({ - http, - alert, -}: { - http: HttpSetup; - alert: Omit< - AlertUpdates, - 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' - >; -}): Promise { - return await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert`, { - body: JSON.stringify(alert), - }); -} - -export async function updateAlert({ - http, - alert, - id, -}: { - http: HttpSetup; - alert: Pick< - AlertUpdates, - 'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'notifyWhen' - >; - id: string; -}): Promise { - return await http.put(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}`, { - body: JSON.stringify( - pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions', 'notifyWhen']) - ), - }); -} - -export async function enableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/_enable`); -} - -export async function enableAlerts({ - ids, - http, -}: { - ids: string[]; - http: HttpSetup; -}): Promise { - await Promise.all(ids.map((id) => enableAlert({ id, http }))); -} - -export async function disableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/_disable`); -} - -export async function disableAlerts({ - ids, - http, -}: { - ids: string[]; - http: HttpSetup; -}): Promise { - await Promise.all(ids.map((id) => disableAlert({ id, http }))); -} - -export async function muteAlertInstance({ - id, - instanceId, - http, -}: { - id: string; - instanceId: string; - http: HttpSetup; -}): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/alert_instance/${instanceId}/_mute`); -} - -export async function unmuteAlertInstance({ - id, - instanceId, - http, -}: { - id: string; - instanceId: string; - http: HttpSetup; -}): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/alert_instance/${instanceId}/_unmute`); -} - -export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/_mute_all`); -} - -export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { - await Promise.all(ids.map((id) => muteAlert({ http, id }))); -} - -export async function unmuteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}/_unmute_all`); -} - -export async function unmuteAlerts({ - ids, - http, -}: { - ids: string[]; - http: HttpSetup; -}): Promise { - await Promise.all(ids.map((id) => unmuteAlert({ id, http }))); -} - -export async function alertingFrameworkHealth({ - http, -}: { - http: HttpSetup; -}): Promise { - return await http.get(`${LEGACY_BASE_ALERT_API_PATH}/_health`); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.test.ts new file mode 100644 index 0000000000000..57feb1e7abae9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.test.ts @@ -0,0 +1,212 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAlertAggregations } from './aggregate'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlertAggregations', () => { + beforeEach(() => jest.resetAllMocks()); + + test('should call aggregate API with base parameters', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertAggregations({ http }); + expect(result).toEqual({ + alertExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call aggregate API with searchText', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertAggregations({ http, searchText: 'apples' }); + expect(result).toEqual({ + alertExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "search": "apples", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call aggregate API with actionTypesFilter', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertAggregations({ + http, + searchText: 'foo', + actionTypesFilter: ['action', 'type'], + }); + expect(result).toEqual({ + alertExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })", + "search": "foo", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call aggregate API with typesFilter', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertAggregations({ + http, + typesFilter: ['foo', 'bar'], + }); + expect(result).toEqual({ + alertExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call aggregate API with actionTypesFilter and typesFilter', async () => { + const resolvedValue = { + rule_execution_status: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertAggregations({ + http, + searchText: 'baz', + actionTypesFilter: ['action', 'type'], + typesFilter: ['foo', 'bar'], + }); + expect(result).toEqual({ + alertExecutionStatus: { + ok: 4, + active: 2, + error: 1, + pending: 1, + unknown: 0, + }, + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/_aggregate", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar) and (alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:type })", + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.ts new file mode 100644 index 0000000000000..589677ec2322d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/aggregate.ts @@ -0,0 +1,44 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { AlertAggregations } from '../../../types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { mapFiltersToKql } from './map_filters_to_kql'; +import { RewriteRequestCase } from '../../../../../actions/common'; + +const rewriteBodyRes: RewriteRequestCase = ({ + rule_execution_status: alertExecutionStatus, + ...rest +}: any) => ({ + ...rest, + alertExecutionStatus, +}); + +export async function loadAlertAggregations({ + http, + searchText, + typesFilter, + actionTypesFilter, + alertStatusesFilter, +}: { + http: HttpSetup; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + alertStatusesFilter?: string[]; +}): Promise { + const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter }); + const res = await http.get(`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`, { + query: { + search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, + search: searchText, + filter: filters.length ? filters.join(' and ') : undefined, + default_search_operator: 'AND', + }, + }); + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts new file mode 100644 index 0000000000000..e94da81d0f5d5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { AlertInstanceSummary } from '../../../../../alerting/common'; +import { loadAlertInstanceSummary } from './alert_summary'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlertInstanceSummary', () => { + test('should call get alert types API', async () => { + const resolvedValue: AlertInstanceSummary = { + instances: {}, + consumer: 'alerts', + enabled: true, + errorMessages: [], + id: 'test', + lastRun: '2021-04-01T22:18:27.609Z', + muteAll: false, + name: 'test', + alertTypeId: '.index-threshold', + status: 'OK', + statusEndDate: '2021-04-01T22:19:25.174Z', + statusStartDate: '2021-04-01T21:19:25.174Z', + tags: [], + throttle: null, + }; + + http.get.mockResolvedValueOnce({ + alerts: {}, + consumer: 'alerts', + enabled: true, + error_messages: [], + id: 'test', + last_run: '2021-04-01T22:18:27.609Z', + mute_all: false, + name: 'test', + rule_type_id: '.index-threshold', + status: 'OK', + status_end_date: '2021-04-01T22:19:25.174Z', + status_start_date: '2021-04-01T21:19:25.174Z', + tags: [], + throttle: null, + }); + + const result = await loadAlertInstanceSummary({ http, alertId: 'test' }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rule/test/_alert_summary", + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts new file mode 100644 index 0000000000000..e37c0640ec1c8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts @@ -0,0 +1,41 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { AlertInstanceSummary } from '../../../types'; +import { RewriteRequestCase } from '../../../../../actions/common'; + +const rewriteBodyRes: RewriteRequestCase = ({ + alerts, + rule_type_id: alertTypeId, + mute_all: muteAll, + status_start_date: statusStartDate, + status_end_date: statusEndDate, + error_messages: errorMessages, + last_run: lastRun, + ...rest +}: any) => ({ + ...rest, + alertTypeId, + muteAll, + statusStartDate, + statusEndDate, + errorMessages, + lastRun, + instances: alerts, +}); + +export async function loadAlertInstanceSummary({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + const res = await http.get(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${alertId}/_alert_summary`); + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts new file mode 100644 index 0000000000000..749cf53cf740b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts @@ -0,0 +1,61 @@ +/* + * 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 { AlertExecutionStatus } from '../../../../../alerting/common'; +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; +import { Alert, AlertAction } from '../../../types'; + +const transformAction: RewriteRequestCase = ({ + group, + id, + connector_type_id: actionTypeId, + params, +}) => ({ + group, + id, + params, + actionTypeId, +}); + +const transformExecutionStatus: RewriteRequestCase = ({ + last_execution_date: lastExecutionDate, + ...rest +}) => ({ + lastExecutionDate, + ...rest, +}); + +export const transformAlert: RewriteRequestCase = ({ + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + mute_all: muteAll, + muted_alert_ids: mutedInstanceIds, + scheduled_task_id: scheduledTaskId, + execution_status: executionStatus, + actions: actions, + ...rest +}: any) => ({ + alertTypeId, + createdBy, + updatedBy, + createdAt, + updatedAt, + apiKeyOwner, + notifyWhen, + muteAll, + mutedInstanceIds, + executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined, + actions: actions + ? actions.map((action: AsApiContract) => transformAction(action)) + : [], + scheduledTaskId, + ...rest, +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.test.ts new file mode 100644 index 0000000000000..8d1ec57a4e63e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { AlertUpdates } from '../../../types'; +import { createAlert } from './create'; + +const http = httpServiceMock.createStartContract(); + +describe('createAlert', () => { + beforeEach(() => jest.resetAllMocks()); + + test('should call create alert API', async () => { + const resolvedValue = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + rule_type_id: '.index-threshold', + notify_when: 'onActionGroupChange', + actions: [ + { + group: 'threshold met', + id: '1', + params: { + level: 'info', + message: 'alert ', + }, + connector_type_id: '.server-log', + }, + ], + scheduled_task_id: '1', + execution_status: { status: 'pending', last_execution_date: '2021-04-01T21:33:13.250Z' }, + create_at: '2021-04-01T21:33:13.247Z', + updated_at: '2021-04-01T21:33:13.247Z', + }; + const alertToCreate: Omit< + AlertUpdates, + 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' + > = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + enabled: true, + throttle: null, + alertTypeId: '.index-threshold', + notifyWhen: 'onActionGroupChange', + actions: [ + { + group: 'threshold met', + id: '83d4d860-9316-11eb-a145-93ab369a4461', + params: { + level: 'info', + message: + "alert '{{alertName}}' is active for group '{{context.group}}':\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}}\n- Timestamp: {{context.date}}", + }, + actionTypeId: '.server-log', + }, + ], + createdAt: new Date('2021-04-01T21:33:13.247Z'), + updatedAt: new Date('2021-04-01T21:33:13.247Z'), + apiKeyOwner: '', + }; + http.post.mockResolvedValueOnce(resolvedValue); + + const result = await createAlert({ http, alert: alertToCreate }); + expect(result).toEqual({ + actions: [ + { + actionTypeId: '.server-log', + group: 'threshold met', + id: '1', + params: { + level: 'info', + message: 'alert ', + }, + }, + ], + alertTypeId: '.index-threshold', + apiKeyOwner: undefined, + consumer: 'alerts', + create_at: '2021-04-01T21:33:13.247Z', + createdAt: undefined, + createdBy: undefined, + executionStatus: { + lastExecutionDate: '2021-04-01T21:33:13.250Z', + status: 'pending', + }, + muteAll: undefined, + mutedInstanceIds: undefined, + name: 'test', + notifyWhen: 'onActionGroupChange', + params: { + aggType: 'count', + groupBy: 'all', + index: ['.kibana'], + termSize: 5, + threshold: [1000], + thresholdComparator: '>', + timeField: 'alert.executionStatus.lastExecutionDate', + timeWindowSize: 5, + timeWindowUnit: 'm', + }, + schedule: { + interval: '1m', + }, + scheduledTaskId: '1', + tags: [], + updatedAt: '2021-04-01T21:33:13.247Z', + updatedBy: undefined, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.ts new file mode 100644 index 0000000000000..bd92769b4bbf3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/create.ts @@ -0,0 +1,44 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { RewriteResponseCase } from '../../../../../actions/common'; +import { Alert, AlertUpdates } from '../../../types'; +import { BASE_ALERTING_API_PATH } from '../../constants'; +import { transformAlert } from './common_transformations'; + +type AlertCreateBody = Omit< + AlertUpdates, + 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' +>; +const rewriteBodyRequest: RewriteResponseCase = ({ + alertTypeId, + notifyWhen, + actions, + ...res +}): any => ({ + ...res, + rule_type_id: alertTypeId, + notify_when: notifyWhen, + actions: actions.map(({ group, id, params }) => ({ + group, + id, + params, + })), +}); + +export async function createAlert({ + http, + alert, +}: { + http: HttpSetup; + alert: AlertCreateBody; +}): Promise { + const res = await http.post(`${BASE_ALERTING_API_PATH}/rule`, { + body: JSON.stringify(rewriteBodyRequest(alert)), + }); + return transformAlert(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts new file mode 100644 index 0000000000000..b279e4c0237d9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { deleteAlerts } from './delete'; + +const http = httpServiceMock.createStartContract(); + +describe('deleteAlerts', () => { + test('should call delete API for each alert', async () => { + const ids = ['1', '2', '3']; + const result = await deleteAlerts({ http, ids }); + expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1", + ], + Array [ + "/api/alerting/rule/2", + ], + Array [ + "/api/alerting/rule/3", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts new file mode 100644 index 0000000000000..870d5a409c3dd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts @@ -0,0 +1,28 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function deleteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise<{ successes: string[]; errors: string[] }> { + const successes: string[] = []; + const errors: string[] = []; + await Promise.all(ids.map((id) => http.delete(`${BASE_ALERTING_API_PATH}/rule/${id}`))).then( + function (fulfilled) { + successes.push(...fulfilled); + }, + function (rejected) { + errors.push(...rejected); + } + ); + return { successes, errors }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts new file mode 100644 index 0000000000000..90d1cd13096e8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { disableAlert, disableAlerts } from './disable'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('disableAlert', () => { + test('should call disable alert API', async () => { + const result = await disableAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_disable", + ], + ] + `); + }); +}); + +describe('disableAlerts', () => { + test('should call disable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await disableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_disable", + ], + Array [ + "/api/alerting/rule/2/_disable", + ], + Array [ + "/api/alerting/rule/3/_disable", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts new file mode 100644 index 0000000000000..cc0939fbebfbd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts @@ -0,0 +1,22 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function disableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_disable`); +} + +export async function disableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map((id) => disableAlert({ id, http }))); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts new file mode 100644 index 0000000000000..ef65e8b605cba --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { enableAlert, enableAlerts } from './enable'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('enableAlert', () => { + test('should call enable alert API', async () => { + const result = await enableAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_enable", + ], + ] + `); + }); +}); + +describe('enableAlerts', () => { + test('should call enable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await enableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_enable", + ], + Array [ + "/api/alerting/rule/2/_enable", + ], + Array [ + "/api/alerting/rule/3/_enable", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts new file mode 100644 index 0000000000000..3c16ffaec6223 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts @@ -0,0 +1,22 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function enableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_enable`); +} + +export async function enableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map((id) => enableAlert({ id, http }))); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts new file mode 100644 index 0000000000000..f2d8337eb4091 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAlert } from './get_rule'; +import uuid from 'uuid'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlert', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + id: '1', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + rule_type_id: '.index-threshold', + created_by: 'elastic', + updated_by: 'elastic', + created_at: '2021-04-01T20:29:18.652Z', + updated_at: '2021-04-01T20:33:38.260Z', + api_key_owner: 'elastic', + notify_when: 'onThrottleInterval', + mute_all: false, + muted_alert_ids: [], + scheduled_task_id: '1', + execution_status: { status: 'ok', last_execution_date: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + connector_type_id: '.index', + }, + ], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlert({ http, alertId })).toEqual({ + id: '1', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + alertTypeId: '.index-threshold', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2021-04-01T20:29:18.652Z', + updatedAt: '2021-04-01T20:33:38.260Z', + apiKeyOwner: 'elastic', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '1', + executionStatus: { status: 'ok', lastExecutionDate: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + actionTypeId: '.index', + }, + ], + }); + expect(http.get).toHaveBeenCalledWith(`/api/alerting/rule/${alertId}`); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts new file mode 100644 index 0000000000000..2e4cbc9b50c51 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts @@ -0,0 +1,21 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { Alert } from '../../../types'; +import { BASE_ALERTING_API_PATH } from '../../constants'; +import { transformAlert } from './common_transformations'; + +export async function loadAlert({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + const res = await http.get(`${BASE_ALERTING_API_PATH}/rule/${alertId}`); + return transformAlert(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.test.ts new file mode 100644 index 0000000000000..e08306bee0f9c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { alertingFrameworkHealth } from './health'; + +describe('alertingFrameworkHealth', () => { + const http = httpServiceMock.createStartContract(); + test('should call alertingFrameworkHealth API', async () => { + http.get.mockResolvedValueOnce({ + is_sufficiently_secure: true, + has_permanent_encryption_key: true, + alerting_framework_heath: { + decryption_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + execution_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + read_health: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, + }); + const result = await alertingFrameworkHealth({ http }); + expect(result).toEqual({ + alertingFrameworkHeath: { + decryptionHealth: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + executionHealth: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + readHealth: { status: 'ok', timestamp: '2021-04-01T21:29:22.991Z' }, + }, + hasPermanentEncryptionKey: true, + isSufficientlySecure: true, + }); + expect(http.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/_health", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.ts new file mode 100644 index 0000000000000..9468f4b3c03e0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/health.ts @@ -0,0 +1,44 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; +import { AlertingFrameworkHealth, AlertsHealth } from '../../../../../alerting/common'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +const rewriteAlertingFrameworkHeath: RewriteRequestCase = ({ + decryption_health: decryptionHealth, + execution_health: executionHealth, + read_health: readHealth, + ...res +}: AsApiContract) => ({ + decryptionHealth, + executionHealth, + readHealth, + ...res, +}); + +const rewriteBodyRes: RewriteRequestCase = ({ + is_sufficiently_secure: isSufficientlySecure, + has_permanent_encryption_key: hasPermanentEncryptionKey, + alerting_framework_heath: alertingFrameworkHeath, + ...res +}: AsApiContract) => ({ + isSufficientlySecure, + hasPermanentEncryptionKey, + alertingFrameworkHeath, + ...res, +}); + +export async function alertingFrameworkHealth({ + http, +}: { + http: HttpSetup; +}): Promise { + const res = await http.get(`${BASE_ALERTING_API_PATH}/_health`); + const alertingFrameworkHeath = rewriteAlertingFrameworkHeath(res.alerting_framework_heath); + return { ...rewriteBodyRes(res), alertingFrameworkHeath }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts new file mode 100644 index 0000000000000..a0b090a474e28 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { alertingFrameworkHealth } from './health'; +export { mapFiltersToKql } from './map_filters_to_kql'; +export { loadAlertAggregations } from './aggregate'; +export { createAlert } from './create'; +export { deleteAlerts } from './delete'; +export { disableAlert, disableAlerts } from './disable'; +export { enableAlert, enableAlerts } from './enable'; +export { loadAlert } from './get_rule'; +export { loadAlertInstanceSummary } from './alert_summary'; +export { muteAlertInstance } from './mute_alert'; +export { muteAlert, muteAlerts } from './mute'; +export { loadAlertTypes } from './rule_types'; +export { loadAlerts } from './rules'; +export { loadAlertState } from './state'; +export { unmuteAlertInstance } from './unmute_alert'; +export { unmuteAlert, unmuteAlerts } from './unmute'; +export { updateAlert } from './update'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.test.ts new file mode 100644 index 0000000000000..4e5e2a412dad6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { mapFiltersToKql } from './map_filters_to_kql'; + +describe('mapFiltersToKql', () => { + beforeEach(() => jest.resetAllMocks()); + + test('should handle no filters', () => { + expect(mapFiltersToKql({})).toEqual([]); + }); + + test('should handle typesFilter', () => { + expect( + mapFiltersToKql({ + typesFilter: ['type', 'filter'], + }) + ).toEqual(['alert.attributes.alertTypeId:(type or filter)']); + }); + + test('should handle actionTypesFilter', () => { + expect( + mapFiltersToKql({ + actionTypesFilter: ['action', 'types', 'filter'], + }) + ).toEqual([ + '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', + ]); + }); + + test('should handle alertStatusesFilter', () => { + expect( + mapFiltersToKql({ + alertStatusesFilter: ['alert', 'statuses', 'filter'], + }) + ).toEqual(['alert.attributes.executionStatus.status:(alert or statuses or filter)']); + }); + + test('should handle typesFilter and actionTypesFilter', () => { + expect( + mapFiltersToKql({ + typesFilter: ['type', 'filter'], + actionTypesFilter: ['action', 'types', 'filter'], + }) + ).toEqual([ + 'alert.attributes.alertTypeId:(type or filter)', + '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', + ]); + }); + + test('should handle typesFilter, actionTypesFilter and alertStatusesFilter', () => { + expect( + mapFiltersToKql({ + typesFilter: ['type', 'filter'], + actionTypesFilter: ['action', 'types', 'filter'], + alertStatusesFilter: ['alert', 'statuses', 'filter'], + }) + ).toEqual([ + 'alert.attributes.alertTypeId:(type or filter)', + '(alert.attributes.actions:{ actionTypeId:action } OR alert.attributes.actions:{ actionTypeId:types } OR alert.attributes.actions:{ actionTypeId:filter })', + 'alert.attributes.executionStatus.status:(alert or statuses or filter)', + ]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.ts new file mode 100644 index 0000000000000..4c30e960034bf --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/map_filters_to_kql.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export const mapFiltersToKql = ({ + typesFilter, + actionTypesFilter, + alertStatusesFilter, +}: { + typesFilter?: string[]; + actionTypesFilter?: string[]; + alertStatusesFilter?: string[]; +}): string[] => { + const filters = []; + if (typesFilter && typesFilter.length) { + filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`); + } + if (actionTypesFilter && actionTypesFilter.length) { + filters.push( + [ + '(', + actionTypesFilter + .map((id) => `alert.attributes.actions:{ actionTypeId:${id} }`) + .join(' OR '), + ')', + ].join('') + ); + } + if (alertStatusesFilter && alertStatusesFilter.length) { + filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`); + } + return filters; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts new file mode 100644 index 0000000000000..75143dd6b7f85 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { muteAlert, muteAlerts } from './mute'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('muteAlert', () => { + test('should call mute alert API', async () => { + const result = await muteAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_mute_all", + ], + ] + `); + }); +}); + +describe('muteAlerts', () => { + test('should call mute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await muteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_mute_all", + ], + Array [ + "/api/alerting/rule/2/_mute_all", + ], + Array [ + "/api/alerting/rule/3/_mute_all", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts new file mode 100644 index 0000000000000..22a96d7a11ff3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts @@ -0,0 +1,16 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_mute_all`); +} + +export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { + await Promise.all(ids.map((id) => muteAlert({ http, id }))); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts new file mode 100644 index 0000000000000..4365cce42c8c3 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { muteAlertInstance } from './mute_alert'; + +const http = httpServiceMock.createStartContract(); + +describe('muteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await muteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/alert/123/_mute", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts new file mode 100644 index 0000000000000..0bb05010cfa3c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts @@ -0,0 +1,20 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function muteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/alert/${instanceId}/_mute`); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.test.ts new file mode 100644 index 0000000000000..71513ed0c6e61 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { AlertType } from '../../../types'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAlertTypes } from './rule_types'; +import { ALERTS_FEATURE_ID } from '../../../../../alerting/common'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlertTypes', () => { + test('should call get alert types API', async () => { + const resolvedValue: AlertType[] = [ + { + id: 'test', + name: 'Test', + actionVariables: { + context: [{ name: 'var1', description: 'val1' }], + state: [{ name: 'var2', description: 'val2' }], + params: [{ name: 'var3', description: 'val3' }], + }, + producer: ALERTS_FEATURE_ID, + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + defaultActionGroupId: 'default', + authorizedConsumers: {}, + minimumLicenseRequired: 'basic', + enabledInLicense: true, + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rule_types", + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts new file mode 100644 index 0000000000000..54369d7959c93 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts @@ -0,0 +1,39 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { AlertType } from '../../../types'; +import { BASE_ALERTING_API_PATH } from '../../constants'; +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; + +const rewriteResponseRes = (results: Array>): AlertType[] => { + return results.map((item) => rewriteBodyReq(item)); +}; + +const rewriteBodyReq: RewriteRequestCase = ({ + enabled_in_license: enabledInLicense, + recovery_action_group: recoveryActionGroup, + action_groups: actionGroups, + default_action_group_id: defaultActionGroupId, + minimum_license_required: minimumLicenseRequired, + action_variables: actionVariables, + authorized_consumers: authorizedConsumers, + ...rest +}: AsApiContract) => ({ + enabledInLicense, + recoveryActionGroup, + actionGroups, + defaultActionGroupId, + minimumLicenseRequired, + actionVariables, + authorizedConsumers, + ...rest, +}); + +export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { + const res = await http.get(`${BASE_ALERTING_API_PATH}/rule_types`); + return rewriteResponseRes(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.test.ts new file mode 100644 index 0000000000000..602507c08066c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.test.ts @@ -0,0 +1,242 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAlerts } from './rules'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlerts', () => { + beforeEach(() => jest.resetAllMocks()); + + test('should call find API with base parameters', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, page: { index: 0, size: 10 } }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); + + test('should call find API with searchText', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "apples", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'foo', + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "foo", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); + + test('should call find API with typesFilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); + + test('should call find API with searchText and tagsFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'apples, foo, baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 0, + data: [], + }); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rules/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "apples, foo, baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name", + "sort_order": "asc", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.ts new file mode 100644 index 0000000000000..f0bbb57180bb4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rules.ts @@ -0,0 +1,59 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; +import { Alert, Pagination, Sorting } from '../../../types'; +import { AsApiContract } from '../../../../../actions/common'; +import { mapFiltersToKql } from './map_filters_to_kql'; +import { transformAlert } from './common_transformations'; + +const rewriteResponseRes = (results: Array>): Alert[] => { + return results.map((item) => transformAlert(item)); +}; + +export async function loadAlerts({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + alertStatusesFilter, + sort = { field: 'name', direction: 'asc' }, +}: { + http: HttpSetup; + page: Pagination; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + alertStatusesFilter?: string[]; + sort?: Sorting; +}): Promise<{ + page: number; + perPage: number; + total: number; + data: Alert[]; +}> { + const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, alertStatusesFilter }); + const res = await http.get(`${BASE_ALERTING_API_PATH}/rules/_find`, { + query: { + page: page.index + 1, + per_page: page.size, + search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, + search: searchText, + filter: filters.length ? filters.join(' and ') : undefined, + default_search_operator: 'AND', + sort_field: sort.field, + sort_order: sort.direction, + }, + }); + return { + page: res.page, + perPage: res.per_page, + total: res.total, + data: rewriteResponseRes(res.data), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.test.ts new file mode 100644 index 0000000000000..ae27352be0b90 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { loadAlertState } from './state'; +import uuid from 'uuid'; + +const http = httpServiceMock.createStartContract(); + +describe('loadAlertState', () => { + beforeEach(() => jest.resetAllMocks()); + + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: {}, + second_instance: {}, + }, + }; + http.get.mockResolvedValueOnce({ + rule_type_state: { + some: 'value', + }, + alerts: { + first_instance: {}, + second_instance: {}, + }, + }); + + expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${alertId}/state`); + }); + + test('should parse AlertInstances', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: '2020-02-09T23:15:41.941Z', + }, + }, + }, + }, + }; + http.get.mockResolvedValueOnce({ + rule_type_state: { + some: 'value', + }, + alerts: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: '2020-02-09T23:15:41.941Z', + }, + }, + }, + }, + }); + + expect(await loadAlertState({ http, alertId })).toEqual({ + ...resolvedValue, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date('2020-02-09T23:15:41.941Z'), + }, + }, + }, + }, + }); + expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${alertId}/state`); + }); + + test('should handle empty response from api', async () => { + const alertId = uuid.v4(); + http.get.mockResolvedValueOnce(''); + + expect(await loadAlertState({ http, alertId })).toEqual({}); + expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${alertId}/state`); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.ts new file mode 100644 index 0000000000000..428bc5b99a70b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/state.ts @@ -0,0 +1,49 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { Errors, identity } from 'io-ts'; +import { AlertTaskState } from '../../../types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { alertStateSchema } from '../../../../../alerting/common'; +import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; + +const rewriteBodyRes: RewriteRequestCase = ({ + rule_type_state: alertTypeState, + alerts: alertInstances, + previous_started_at: previousStartedAt, + ...rest +}: any) => ({ + ...rest, + alertTypeState, + alertInstances, + previousStartedAt, +}); + +type EmptyHttpResponse = ''; +export async function loadAlertState({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http + .get(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${alertId}/state`) + .then((state: AsApiContract | EmptyHttpResponse) => + state ? rewriteBodyRes(state) : {} + ) + .then((state: AlertTaskState) => { + return pipe( + alertStateSchema.decode(state), + fold((e: Errors) => { + throw new Error(`Alert "${alertId}" has invalid state`); + }, identity) + ); + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts new file mode 100644 index 0000000000000..68a6feeb65e1e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { unmuteAlert, unmuteAlerts } from './unmute'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('unmuteAlerts', () => { + test('should call unmute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await unmuteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_unmute_all", + ], + Array [ + "/api/alerting/rule/2/_unmute_all", + ], + Array [ + "/api/alerting/rule/3/_unmute_all", + ], + ] + `); + }); +}); + +describe('unmuteAlert', () => { + test('should call unmute alert API', async () => { + const result = await unmuteAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/_unmute_all", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts new file mode 100644 index 0000000000000..c65be6a670a89 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts @@ -0,0 +1,22 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function unmuteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_unmute_all`); +} + +export async function unmuteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map((id) => unmuteAlert({ id, http }))); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts new file mode 100644 index 0000000000000..c0131cbab0ebf --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { unmuteAlertInstance } from './unmute_alert'; + +const http = httpServiceMock.createStartContract(); + +describe('unmuteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await unmuteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alerting/rule/1/alert/123/_unmute", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts new file mode 100644 index 0000000000000..60d2cca72b85e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts @@ -0,0 +1,20 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERTING_API_PATH } from '../../constants'; + +export async function unmuteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/alert/${instanceId}/_unmute`); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts new file mode 100644 index 0000000000000..745a94b8d1134 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { Alert } from '../../../types'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { updateAlert } from './update'; +import { AlertNotifyWhenType } from '../../../../../alerting/common'; + +const http = httpServiceMock.createStartContract(); + +describe('updateAlert', () => { + test('should call alert update API', async () => { + const alertToUpdate = { + throttle: '1m', + consumer: 'alerts', + name: 'test', + tags: ['foo'], + schedule: { + interval: '1m', + }, + params: {}, + actions: [], + createdAt: new Date('1970-01-01T00:00:00.000Z'), + updatedAt: new Date('1970-01-01T00:00:00.000Z'), + apiKey: null, + apiKeyOwner: null, + notifyWhen: 'onThrottleInterval' as AlertNotifyWhenType, + }; + const resolvedValue: Alert = { + ...alertToUpdate, + id: '123', + enabled: true, + alertTypeId: 'test', + createdBy: null, + updatedBy: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + }; + http.put.mockResolvedValueOnce(resolvedValue); + + const result = await updateAlert({ http, id: '123', alert: alertToUpdate }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alerting/rule/123", + Object { + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"notify_when\\":\\"onThrottleInterval\\",\\"actions\\":[]}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts new file mode 100644 index 0000000000000..44b9306949f81 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts @@ -0,0 +1,52 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { pick } from 'lodash'; +import { BASE_ALERTING_API_PATH } from '../../constants'; +import { Alert, AlertUpdates } from '../../../types'; +import { RewriteResponseCase } from '../../../../../actions/common'; +import { transformAlert } from './common_transformations'; + +type AlertUpdatesBody = Pick< + AlertUpdates, + 'name' | 'tags' | 'schedule' | 'actions' | 'params' | 'throttle' | 'notifyWhen' +>; +const rewriteBodyRequest: RewriteResponseCase = ({ + notifyWhen, + actions, + ...res +}): any => ({ + ...res, + notify_when: notifyWhen, + actions: actions.map(({ group, id, params }) => ({ + group, + id, + params, + })), +}); + +export async function updateAlert({ + http, + alert, + id, +}: { + http: HttpSetup; + alert: Pick< + AlertUpdates, + 'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'notifyWhen' + >; + id: string; +}): Promise { + const res = await http.put(`${BASE_ALERTING_API_PATH}/rule/${id}`, { + body: JSON.stringify( + rewriteBodyRequest( + pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions', 'notifyWhen']) + ) + ), + }); + return transformAlert(res); +}