From d3ab62c0afbac42e820fe7bda875b91837ea418d Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 17 Jul 2019 19:01:12 -0600 Subject: [PATCH] [SIEM] Add toaster logic for Machine Learning (#41401) (#41420) ## Summary Fixes two blockers: 1) Anomaly table would spin forever if you zero jobs configured on a fresh install. 2) If you get Network or ML errors this will report the error to the user in the form of the global toaster. Two examples of Error Toaster before the "See the full error(s)" is clicked: ![toast](https://user-images.githubusercontent.com/1151048/61415060-f2089200-a8ac-11e9-8293-3dfcdbbe069e.png) Screen Shot 2019-07-17 at 4 04 30 PM Example Error Toasters from start job expanded and collapsed: Screen Shot 2019-07-17 at 12 15 04 PM Screen Shot 2019-07-17 at 12 14 58 PM Example Network Error Toaster: Screen Shot 2019-07-17 at 12 21 10 PM Example API Error if you send in something bad such as a bad payload: Screen Shot 2019-07-17 at 12 34 04 PM Example Anomalies Table Error if you have a network issue: Screen Shot 2019-07-17 at 12 39 57 PM ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) - [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- .../components/ml/anomaly/translations.ts | 14 + .../ml/anomaly/use_anomalies_table_data.ts | 50 +-- .../components/ml/api/anomalies_table_data.ts | 36 +-- .../ml/api/error_to_toaster.test.ts | 126 ++++++++ .../components/ml/api/error_to_toaster.ts | 67 ++++ .../components/ml/api/get_ml_capabilities.ts | 30 +- .../components/ml/api/throw_if_not_ok.test.ts | 254 ++++++++++++--- .../components/ml/api/throw_if_not_ok.ts | 72 ++++- .../public/components/ml/api/translations.ts | 21 ++ .../permissions/ml_capabilities_provider.tsx | 17 +- .../components/ml/permissions/translations.ts | 14 + .../siem/public/components/ml_popover/api.tsx | 295 ++++++++---------- .../ml_popover/hooks/translations.ts | 28 ++ .../ml_popover/hooks/use_index_patterns.tsx | 20 +- .../ml_popover/hooks/use_job_summary_data.tsx | 25 +- .../ml_popover/hooks/use_siem_jobs.tsx | 19 +- .../components/ml_popover/ml_popover.tsx | 46 ++- .../components/ml_popover/translations.ts | 18 ++ 18 files changed, 851 insertions(+), 301 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/anomaly/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/permissions/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/translations.ts new file mode 100644 index 000000000000..227400a0e83c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SIEM_TABLE_FETCH_FAILURE = i18n.translate( + 'xpack.siem.components.ml.anomaly.errors.anomaliesTableFetchFailureTitle', + { + defaultMessage: 'Anomalies table fetch failure', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index beb23ee1051c..4bd526f9e56c 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -15,6 +15,10 @@ import { import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs'; +import { useStateToaster } from '../../toasters'; +import { errorToToaster } from '../api/error_to_toaster'; + +import * as i18n from './translations'; interface Args { influencers?: InfluencerInput[]; @@ -75,6 +79,7 @@ export const useAnomaliesTableData = ({ const config = useContext(KibanaConfigContext); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); + const [, dispatchToaster] = useStateToaster(); const fetchFunc = async ( influencersInput: InfluencerInput[], @@ -83,27 +88,34 @@ export const useAnomaliesTableData = ({ latestMs: number ) => { if (userPermissions && !skip && siemJobs.length > 0) { - const data = await anomaliesTableData( - { - jobIds: siemJobs, - criteriaFields: criteriaFieldsInput, - aggregationInterval: 'auto', - threshold: getThreshold(config, threshold), - earliestMs, - latestMs, - influencers: influencersInput, - dateFormatTz: getTimeZone(config), - maxRecords: 500, - maxExamples: 10, - }, - { - 'kbn-version': config.kbnVersion, - } - ); - setTableData(data); - setLoading(false); + try { + const data = await anomaliesTableData( + { + jobIds: siemJobs, + criteriaFields: criteriaFieldsInput, + aggregationInterval: 'auto', + threshold: getThreshold(config, threshold), + earliestMs, + latestMs, + influencers: influencersInput, + dateFormatTz: getTimeZone(config), + maxRecords: 500, + maxExamples: 10, + }, + { + 'kbn-version': config.kbnVersion, + } + ); + setTableData(data); + setLoading(false); + } catch (error) { + errorToToaster({ title: i18n.SIEM_TABLE_FETCH_FAILURE, error, dispatchToaster }); + setLoading(false); + } } else if (!userPermissions) { setLoading(false); + } else if (siemJobs.length === 0) { + setLoading(false); } else { setTableData(null); setLoading(true); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts index de277baa9416..5a5a3e13d547 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts @@ -21,31 +21,21 @@ export interface Body { maxExamples: number; } -const empty: Anomalies = { - anomalies: [], - interval: 'second', -}; - export const anomaliesTableData = async ( body: Body, headers: Record ): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/results/anomalies_table_data`, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify(body), - headers: { - 'kbn-system-api': 'true', - 'content-Type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return empty; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/results/anomalies_table_data`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify(body), + headers: { + 'kbn-system-api': 'true', + 'content-Type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + await throwIfNotOk(response); + return await response.json(); }; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts new file mode 100644 index 000000000000..507d6cf98ed0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isAnError, isToasterError, errorToToaster } from './error_to_toaster'; +import { ToasterErrors } from './throw_if_not_ok'; + +describe('error_to_toaster', () => { + let dispatchToaster = jest.fn(); + + beforeEach(() => { + dispatchToaster = jest.fn(); + }); + + describe('#errorToToaster', () => { + test('adds a ToastError given multiple toaster errors', () => { + const error = new ToasterErrors(['some error 1', 'some error 2']); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1', 'some error 2'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('adds a ToastError given a single toaster errors', () => { + const error = new ToasterErrors(['some error 1']); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('adds a regular Error given a single error', () => { + const error = new Error('some error 1'); + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['some error 1'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + + test('adds a generic Network Error given a non Error object such as a string', () => { + const error = 'terrible string'; + errorToToaster({ id: 'some-made-up-id', title: 'some title', error, dispatchToaster }); + expect(dispatchToaster.mock.calls[0]).toEqual([ + { + toast: { + color: 'danger', + errors: ['Network Error'], + iconType: 'alert', + id: 'some-made-up-id', + title: 'some title', + }, + type: 'addToaster', + }, + ]); + }); + }); + + describe('#isAnError', () => { + test('returns true if given an error object', () => { + const error = new Error('some error'); + expect(isAnError(error)).toEqual(true); + }); + + test('returns false if given a regular object', () => { + expect(isAnError({})).toEqual(false); + }); + + test('returns false if given a string', () => { + expect(isAnError('som string')).toEqual(false); + }); + + test('returns true if given a toaster error', () => { + const error = new ToasterErrors(['some error']); + expect(isAnError(error)).toEqual(true); + }); + }); + + describe('#isToasterError', () => { + test('returns true if given a ToasterErrors object', () => { + const error = new ToasterErrors(['some error']); + expect(isToasterError(error)).toEqual(true); + }); + + test('returns false if given a regular object', () => { + expect(isToasterError({})).toEqual(false); + }); + + test('returns false if given a string', () => { + expect(isToasterError('som string')).toEqual(false); + }); + + test('returns false if given a regular error', () => { + const error = new Error('some error'); + expect(isToasterError(error)).toEqual(false); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts new file mode 100644 index 000000000000..779befaa0cd8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isError } from 'lodash/fp'; +import uuid from 'uuid'; +import { ActionToaster, AppToast } from '../../toasters'; +import { ToasterErrorsType, ToasterErrors } from './throw_if_not_ok'; + +export type ErrorToToasterArgs = Partial & { + error: unknown; + dispatchToaster: React.Dispatch; +}; + +export const errorToToaster = ({ + id = uuid.v4(), + title, + error, + color = 'danger', + iconType = 'alert', + dispatchToaster, +}: ErrorToToasterArgs) => { + if (isToasterError(error)) { + const toast: AppToast = { + id, + title, + color, + iconType, + errors: error.messages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); + } else if (isAnError(error)) { + const toast: AppToast = { + id, + title, + color, + iconType, + errors: [error.message], + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); + } else { + const toast: AppToast = { + id, + title, + color, + iconType, + errors: ['Network Error'], + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); + } +}; + +export const isAnError = (error: unknown): error is Error => isError(error); + +export const isToasterError = (error: unknown): error is ToasterErrorsType => + error instanceof ToasterErrors; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts index 388c8df40006..082e08f7b33a 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts @@ -7,7 +7,6 @@ import chrome from 'ui/chrome'; import { InfluencerInput, MlCapabilities } from '../types'; import { throwIfNotOk } from './throw_if_not_ok'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; export interface Body { jobIds: string[]; @@ -25,21 +24,16 @@ export interface Body { export const getMlCapabilities = async ( headers: Record ): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/ml_capabilities`, { - method: 'GET', - credentials: 'same-origin', - headers: { - 'kbn-system-api': 'true', - 'content-Type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyMlCapabilities; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/ml_capabilities`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'kbn-system-api': 'true', + 'content-Type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + await throwIfNotOk(response); + return await response.json(); }; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts index e022c10ce5c7..fd2f29bc1ed0 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts @@ -6,58 +6,240 @@ // @ts-ignore import fetchMock from 'fetch-mock'; -import { throwIfNotOk, parseJsonFromBody, MessageBody } from './throw_if_not_ok'; +import { + throwIfNotOk, + parseJsonFromBody, + MessageBody, + tryParseResponse, + throwIfErrorAttached, + isMlStartJobError, + ToasterErrors, +} from './throw_if_not_ok'; describe('throw_if_not_ok', () => { afterEach(() => { fetchMock.reset(); }); - test('does a throw if it is given response that is not ok and the body is not parseable', async () => { - fetchMock.mock('http://example.com', 500); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).rejects.toThrow('Network Error: Internal Server Error'); + describe('#throwIfNotOk', () => { + test('does a throw if it is given response that is not ok and the body is not parsable', async () => { + fetchMock.mock('http://example.com', 500); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).rejects.toThrow('Network Error: Internal Server Error'); + }); + + test('does a throw and returns a body if it is parsable', async () => { + fetchMock.mock('http://example.com', { + status: 500, + body: { + statusCode: 500, + message: 'I am a custom message', + }, + }); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).rejects.toThrow('I am a custom message'); + }); + + test('does NOT do a throw if it is given response is not ok', async () => { + fetchMock.mock('http://example.com', 200); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).resolves.toEqual(undefined); + }); }); - test('does a throw and returns a body if it is parsable', async () => { - fetchMock.mock('http://example.com', { - status: 500, - body: { + describe('#parseJsonFromBody', () => { + test('parses a json from the body correctly', async () => { + fetchMock.mock('http://example.com', { + status: 500, + body: { + error: 'some error', + statusCode: 500, + message: 'I am a custom message', + }, + }); + const response = await fetch('http://example.com'); + const expected: MessageBody = { + error: 'some error', statusCode: 500, message: 'I am a custom message', - }, + }; + await expect(parseJsonFromBody(response)).resolves.toEqual(expected); + }); + + test('returns null if the body does not exist', async () => { + fetchMock.mock('http://example.com', { status: 500, body: 'some text' }); + const response = await fetch('http://example.com'); + await expect(parseJsonFromBody(response)).resolves.toEqual(null); }); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).rejects.toThrow('I am a custom message'); }); - test('does NOT do a throw if it is given response is not ok', async () => { - fetchMock.mock('http://example.com', 200); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).resolves.toEqual(undefined); + describe('#tryParseResponse', () => { + test('It formats a JSON object', () => { + const parsed = tryParseResponse(JSON.stringify({ hello: 'how are you?' })); + expect(parsed).toEqual('{\n "hello": "how are you?"\n}'); + }); + + test('It returns a string as is if that string is not JSON', () => { + const parsed = tryParseResponse('some string'); + expect(parsed).toEqual('some string'); + }); }); - test('parses a json from the body correctly', async () => { - fetchMock.mock('http://example.com', { - status: 500, - body: { - error: 'some error', - statusCode: 500, - message: 'I am a custom message', - }, - }); - const response = await fetch('http://example.com'); - const expected: MessageBody = { - error: 'some error', - statusCode: 500, - message: 'I am a custom message', - }; - await expect(parseJsonFromBody(response)).resolves.toEqual(expected); + describe('#isMlErrorMsg', () => { + test('It returns true for a ml error msg json', () => { + const json: Record> = { + error: { + msg: 'some message', + response: 'some response', + statusCode: 400, + }, + }; + expect(isMlStartJobError(json)).toEqual(true); + }); + + test('It returns false to a ml error msg if it is missing msg', () => { + const json: Record> = { + error: { + response: 'some response', + statusCode: 400, + }, + }; + expect(isMlStartJobError(json)).toEqual(false); + }); + + test('It returns false to a ml error msg if it is missing response', () => { + const json: Record> = { + error: { + response: 'some response', + statusCode: 400, + }, + }; + expect(isMlStartJobError(json)).toEqual(false); + }); + + test('It returns false to a ml error msg if it is missing statusCode', () => { + const json: Record> = { + error: { + msg: 'some message', + response: 'some response', + }, + }; + expect(isMlStartJobError(json)).toEqual(false); + }); + + test('It returns false to a ml error msg if it is missing error completely', () => { + const json: Record> = {}; + expect(isMlStartJobError(json)).toEqual(false); + }); }); - test('returns null if the body does not exist', async () => { - fetchMock.mock('http://example.com', { status: 500, body: 'some text' }); - const response = await fetch('http://example.com'); - await expect(parseJsonFromBody(response)).resolves.toEqual(null); + describe('#throwIfErrorAttached', () => { + test('It throws if an error is attached', async () => { + const json: Record> = { + 'some-id': { + error: { + msg: 'some message', + response: 'some response', + statusCode: 400, + }, + }, + }; + expect(() => throwIfErrorAttached(json, ['some-id'])).toThrow( + new ToasterErrors(['some message']) + ); + }); + + test('It throws if an error is attached and has all the messages expected', async () => { + const json: Record> = { + 'some-id': { + error: { + msg: 'some message', + response: 'some response', + statusCode: 400, + }, + }, + }; + try { + throwIfErrorAttached(json, ['some-id']); + } catch (error) { + expect(error.messages).toEqual(['some message', 'some response', 'Status Code: 400']); + } + }); + + test('It throws if an error with the response parsed correctly', async () => { + const json: Record> = { + 'some-id': { + error: { + msg: 'some message', + response: JSON.stringify({ hello: 'how are you?' }), + statusCode: 400, + }, + }, + }; + try { + throwIfErrorAttached(json, ['some-id']); + } catch (error) { + expect(error.messages).toEqual([ + 'some message', + '{\n "hello": "how are you?"\n}', + 'Status Code: 400', + ]); + } + }); + + test('It throws if an error is attached and has all the messages expected with multiple ids', async () => { + const json: Record> = { + 'some-id-1': { + error: { + msg: 'some message 1', + response: 'some response 1', + statusCode: 400, + }, + }, + 'some-id-2': { + error: { + msg: 'some message 2', + response: 'some response 2', + statusCode: 500, + }, + }, + }; + try { + throwIfErrorAttached(json, ['some-id-1', 'some-id-2']); + } catch (error) { + expect(error.messages).toEqual([ + 'some message 1', + 'some response 1', + 'Status Code: 400', + 'some message 2', + 'some response 2', + 'Status Code: 500', + ]); + } + }); + + test('It throws if an error is attached and has all the messages expected with multiple ids but only one valid one is given', async () => { + const json: Record> = { + 'some-id-1': { + error: { + msg: 'some message 1', + response: 'some response 1', + statusCode: 400, + }, + }, + 'some-id-2': { + error: { + msg: 'some message 2', + response: 'some response 2', + statusCode: 500, + }, + }, + }; + try { + throwIfErrorAttached(json, ['some-id-1', 'some-id-not-here']); + } catch (error) { + expect(error.messages).toEqual(['some message 1', 'some response 1', 'Status Code: 400']); + } + }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts index 484f0cb8123d..1891f6c2806c 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts @@ -4,19 +4,50 @@ * you may not use this file except in compliance with the Elastic License. */ +import { has } from 'lodash/fp'; + +import * as i18n from './translations'; + export interface MessageBody { error?: string; message?: string; statusCode?: number; } +export interface MlStartJobError { + error: { + msg: string; + response: string; + statusCode: number; + }; + started: boolean; +} + +export type ToasterErrorsType = Error & { + messages: string[]; +}; + +export class ToasterErrors extends Error implements ToasterErrorsType { + public messages: string[]; + + constructor(messages: string[]) { + super(messages[0]); + this.name = 'ToasterErrors'; + this.messages = messages; + } +} + export const throwIfNotOk = async (response: Response): Promise => { if (!response.ok) { const body = await parseJsonFromBody(response); if (body != null && body.message) { - throw new Error(body.message); + if (body.statusCode != null) { + throw new ToasterErrors([body.message, `${i18n.STATUS_CODE} ${body.statusCode}`]); + } else { + throw new ToasterErrors([body.message]); + } } else { - throw new Error(`Network Error: ${response.statusText}`); + throw new ToasterErrors([`${i18n.NETWORK_ERROR} ${response.statusText}`]); } } }; @@ -29,3 +60,40 @@ export const parseJsonFromBody = async (response: Response): Promise { + try { + return JSON.stringify(JSON.parse(response), null, 2); + } catch (error) { + return response; + } +}; + +export const throwIfErrorAttached = ( + json: Record>, + dataFeedIds: string[] +): void => { + const errors = dataFeedIds.reduce((accum, dataFeedId) => { + const dataFeed = json[dataFeedId]; + if (isMlStartJobError(dataFeed)) { + accum = [ + ...accum, + dataFeed.error.msg, + tryParseResponse(dataFeed.error.response), + `${i18n.STATUS_CODE} ${dataFeed.error.statusCode}`, + ]; + return accum; + } else { + return accum; + } + }, []); + if (errors.length > 0) { + throw new ToasterErrors(errors); + } +}; + +// use the "in operator" and regular type guards to do a narrow once this issue is fixed below: +// https://github.com/microsoft/TypeScript/issues/21732 +// Otherwise for now, has will work ok even though it casts 'unknown' to 'any' +export const isMlStartJobError = (value: unknown): value is MlStartJobError => + has('error.msg', value) && has('error.response', value) && has('error.statusCode', value); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts new file mode 100644 index 000000000000..2bf5a1a54626 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const STATUS_CODE = i18n.translate( + 'xpack.siem.components.ml.api.errors.statusCodeFailureTitle', + { + defaultMessage: 'Status Code:', + } +); + +export const NETWORK_ERROR = i18n.translate( + 'xpack.siem.components.ml.api.errors.networkErrorFailureTitle', + { + defaultMessage: 'Network Error:', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index 03675a6dff85..ca24c61e0db3 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -9,16 +9,29 @@ import { MlCapabilities } from '../types'; import { getMlCapabilities } from '../api/get_ml_capabilities'; import { KibanaConfigContext } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { emptyMlCapabilities } from '../empty_ml_capabilities'; +import { errorToToaster } from '../api/error_to_toaster'; +import { useStateToaster } from '../../toasters'; + +import * as i18n from './translations'; export const MlCapabilitiesContext = React.createContext(emptyMlCapabilities); export const MlCapabilitiesProvider = React.memo<{ children: JSX.Element }>(({ children }) => { const [capabilities, setCapabilities] = useState(emptyMlCapabilities); const config = useContext(KibanaConfigContext); + const [, dispatchToaster] = useStateToaster(); const fetchFunc = async () => { - const mlCapabilities = await getMlCapabilities({ 'kbn-version': config.kbnVersion }); - setCapabilities(mlCapabilities); + try { + const mlCapabilities = await getMlCapabilities({ 'kbn-version': config.kbnVersion }); + setCapabilities(mlCapabilities); + } catch (error) { + errorToToaster({ + title: i18n.MACHINE_LEARNING_PERMISSIONS_FAILURE, + error, + dispatchToaster, + }); + } }; useEffect(() => { diff --git a/x-pack/legacy/plugins/siem/public/components/ml/permissions/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml/permissions/translations.ts new file mode 100644 index 000000000000..325a14b71fcb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/permissions/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const MACHINE_LEARNING_PERMISSIONS_FAILURE = i18n.translate( + 'xpack.siem.components.ml.permissions.errors.machineLearningPermissionsFailureTitle', + { + defaultMessage: 'Machine learning permissions failure', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx index a92b506504ac..6f51ff301fe5 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx @@ -15,17 +15,7 @@ import { StartDatafeedResponse, StopDatafeedResponse, } from './types'; -import { throwIfNotOk } from '../ml/api/throw_if_not_ok'; - -const emptyGroup: Group[] = []; - -const emptyMlResponse: SetupMlResponse = { jobs: [], datafeeds: [], kibana: {} }; - -const emptyStartDatafeedResponse: StartDatafeedResponse = {}; - -const emptyStopDatafeeds: [StopDatafeedResponse, CloseJobsResponse] = [{}, {}]; - -const emptyJob: Job[] = []; +import { throwIfNotOk, throwIfErrorAttached } from '../ml/api/throw_if_not_ok'; const emptyIndexPattern: string = ''; @@ -35,23 +25,18 @@ const emptyIndexPattern: string = ''; * @param headers */ export const groupsData = async (headers: Record): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/groups`, { - method: 'GET', - credentials: 'same-origin', - headers: { - 'content-type': 'application/json', - 'kbn-system-api': 'true', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyGroup; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/groups`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-system-api': 'true', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + await throwIfNotOk(response); + return await response.json(); }; /** @@ -70,30 +55,25 @@ export const setupMlJob = async ({ prefix = '', headers = {}, }: MlSetupArgs): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/modules/setup/${configTemplate}`, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ - prefix, - groups, - indexPatternName, - startDatafeed: false, - useDedicatedIndex: false, - }), - headers: { - 'kbn-system-api': 'true', - 'content-type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyMlResponse; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/modules/setup/${configTemplate}`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + prefix, + groups, + indexPatternName, + startDatafeed: false, + useDedicatedIndex: false, + }), + headers: { + 'kbn-system-api': 'true', + 'content-type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + await throwIfNotOk(response); + return await response.json(); }; /** @@ -108,27 +88,24 @@ export const startDatafeeds = async ( headers: Record, start = 0 ): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/force_start_datafeeds`, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ - datafeedIds, - ...(start !== 0 && { start }), - }), - headers: { - 'kbn-system-api': 'true', - 'content-type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyStartDatafeedResponse; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/force_start_datafeeds`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + datafeedIds, + ...(start !== 0 && { start }), + }), + headers: { + 'kbn-system-api': 'true', + 'content-type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + await throwIfNotOk(response); + const json = await response.json(); + throwIfErrorAttached(json, datafeedIds); + return json; }; /** @@ -141,52 +118,44 @@ export const stopDatafeeds = async ( datafeedIds: string[], headers: Record ): Promise<[StopDatafeedResponse, CloseJobsResponse]> => { - try { - const stopDatafeedsResponse = await fetch( - `${chrome.getBasePath()}/api/ml/jobs/stop_datafeeds`, - { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ - datafeedIds, - }), - headers: { - 'kbn-system-api': 'true', - 'content-type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - } - ); - - await throwIfNotOk(stopDatafeedsResponse); - const stopDatafeedsResponseJson = await stopDatafeedsResponse.json(); - - const datafeedPrefix = 'datafeed-'; - const closeJobsResponse = await fetch(`${chrome.getBasePath()}/api/ml/jobs/close_jobs`, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ - jobIds: datafeedIds.map(dataFeedId => - dataFeedId.startsWith(datafeedPrefix) - ? dataFeedId.substring(datafeedPrefix.length) - : dataFeedId - ), - }), - headers: { - 'content-type': 'application/json', - 'kbn-system-api': 'true', - 'kbn-xsrf': chrome.getXsrfToken(), - ...headers, - }, - }); - - await throwIfNotOk(stopDatafeedsResponseJson); - return [stopDatafeedsResponseJson, await closeJobsResponse.json()]; - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyStopDatafeeds; - } + const stopDatafeedsResponse = await fetch(`${chrome.getBasePath()}/api/ml/jobs/stop_datafeeds`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + datafeedIds, + }), + headers: { + 'kbn-system-api': 'true', + 'content-type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + + await throwIfNotOk(stopDatafeedsResponse); + const stopDatafeedsResponseJson = await stopDatafeedsResponse.json(); + + const datafeedPrefix = 'datafeed-'; + const closeJobsResponse = await fetch(`${chrome.getBasePath()}/api/ml/jobs/close_jobs`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + jobIds: datafeedIds.map(dataFeedId => + dataFeedId.startsWith(datafeedPrefix) + ? dataFeedId.substring(datafeedPrefix.length) + : dataFeedId + ), + }), + headers: { + 'content-type': 'application/json', + 'kbn-system-api': 'true', + 'kbn-xsrf': chrome.getXsrfToken(), + ...headers, + }, + }); + + await throwIfNotOk(closeJobsResponse); + return [stopDatafeedsResponseJson, await closeJobsResponse.json()]; }; /** @@ -199,24 +168,19 @@ export const jobsSummary = async ( jobIds: string[], headers: Record ): Promise => { - try { - const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/jobs_summary`, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ jobIds }), - headers: { - 'content-type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - 'kbn-system-api': 'true', - ...headers, - }, - }); - await throwIfNotOk(response); - return await response.json(); - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data - return emptyJob; - } + const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/jobs_summary`, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ jobIds }), + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + 'kbn-system-api': 'true', + ...headers, + }, + }); + await throwIfNotOk(response); + return await response.json(); }; /** @@ -227,38 +191,33 @@ export const jobsSummary = async ( export const getIndexPatterns = async ( headers: Record ): Promise => { - try { - const response = await fetch( - `${chrome.getBasePath()}/api/saved_objects/_find?type=index-pattern&fields=title&fields=type&per_page=10000`, - { - method: 'GET', - credentials: 'same-origin', - headers: { - 'content-type': 'application/json', - 'kbn-xsrf': chrome.getXsrfToken(), - 'kbn-system-api': 'true', - ...headers, - }, - } - ); - await throwIfNotOk(response); - const results: IndexPatternResponse = await response.json(); - - if (results.saved_objects && Array.isArray(results.saved_objects)) { - return results.saved_objects - .reduce( - (acc: string[], v) => [ - ...acc, - ...(v.attributes && v.attributes.title ? [v.attributes.title] : []), - ], - [] - ) - .join(', '); - } else { - return emptyIndexPattern; + const response = await fetch( + `${chrome.getBasePath()}/api/saved_objects/_find?type=index-pattern&fields=title&fields=type&per_page=10000`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': chrome.getXsrfToken(), + 'kbn-system-api': 'true', + ...headers, + }, } - } catch (error) { - // TODO: Toaster error when this happens instead of returning empty data + ); + await throwIfNotOk(response); + const results: IndexPatternResponse = await response.json(); + + if (results.saved_objects && Array.isArray(results.saved_objects)) { + return results.saved_objects + .reduce( + (acc: string[], v) => [ + ...acc, + ...(v.attributes && v.attributes.title ? [v.attributes.title] : []), + ], + [] + ) + .join(', '); + } else { return emptyIndexPattern; } }; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts new file mode 100644 index 000000000000..3982931a2b7c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const INDEX_PATTERN_FETCH_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle', + { + defaultMessage: 'Index pattern fetch failure', + } +); + +export const JOB_SUMMARY_FETCH_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.hooks.errors.jobSummaryFetchFailureTitle', + { + defaultMessage: 'Job summary fetch failure', + } +); + +export const SIEM_JOB_FETCH_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.hooks.errors.siemJobFetchFailureTitle', + { + defaultMessage: 'SIEM job fetch failure', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx index 40711fababf3..56f6942299c1 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx @@ -7,6 +7,10 @@ import { useContext, useEffect, useState } from 'react'; import { getIndexPatterns } from '../api'; import { KibanaConfigContext } from '../../../lib/adapters/framework/kibana_framework_adapter'; +import { useStateToaster } from '../../toasters'; +import { errorToToaster } from '../../ml/api/error_to_toaster'; + +import * as i18n from './translations'; type Return = [boolean, string]; @@ -14,14 +18,20 @@ export const useIndexPatterns = (refreshToggle = false): Return => { const [indexPattern, setIndexPattern] = useState(''); const [isLoading, setIsLoading] = useState(true); const config = useContext(KibanaConfigContext); + const [, dispatchToaster] = useStateToaster(); const fetchFunc = async () => { - const data = await getIndexPatterns({ - 'kbn-version': config.kbnVersion, - }); + try { + const data = await getIndexPatterns({ + 'kbn-version': config.kbnVersion, + }); - setIndexPattern(data); - setIsLoading(false); + setIndexPattern(data); + setIsLoading(false); + } catch (error) { + errorToToaster({ title: i18n.INDEX_PATTERN_FETCH_FAILURE, error, dispatchToaster }); + setIsLoading(false); + } }; useEffect(() => { diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx index 0398a3f41ac4..8b7a6dcb9138 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx @@ -10,6 +10,10 @@ import { Job } from '../types'; import { KibanaConfigContext } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; +import { useStateToaster } from '../../toasters'; +import { errorToToaster } from '../../ml/api/error_to_toaster'; + +import * as i18n from './translations'; type Return = [boolean, Job[] | null]; @@ -24,17 +28,22 @@ export const useJobSummaryData = (jobIds: string[] = [], refreshToggle = false): const config = useContext(KibanaConfigContext); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); + const [, dispatchToaster] = useStateToaster(); const fetchFunc = async () => { if (userPermissions) { - const data: Job[] = await jobsSummary(jobIds, { - 'kbn-version': config.kbnVersion, - }); - - // TODO: API returns all jobs even though we specified jobIds -- jobsSummary call seems to match request in ML App? - const siemJobs = getSiemJobsFromJobsSummary(data); - - setJobSummaryData(siemJobs); + try { + const data: Job[] = await jobsSummary(jobIds, { + 'kbn-version': config.kbnVersion, + }); + + // TODO: API returns all jobs even though we specified jobIds -- jobsSummary call seems to match request in ML App? + const siemJobs = getSiemJobsFromJobsSummary(data); + + setJobSummaryData(siemJobs); + } catch (error) { + errorToToaster({ title: i18n.JOB_SUMMARY_FETCH_FAILURE, error, dispatchToaster }); + } } setLoading(false); }; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index 153e9539330e..23992ae811d4 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -10,6 +10,10 @@ import { Group } from '.././types'; import { KibanaConfigContext } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; +import { useStateToaster } from '../../toasters'; +import { errorToToaster } from '../../ml/api/error_to_toaster'; + +import * as i18n from './translations'; type Return = [boolean, string[]]; @@ -24,16 +28,21 @@ export const useSiemJobs = (refetchData: boolean): Return => { const config = useContext(KibanaConfigContext); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); + const [, dispatchToaster] = useStateToaster(); const fetchFunc = async () => { if (userPermissions) { - const data = await groupsData({ - 'kbn-version': config.kbnVersion, - }); + try { + const data = await groupsData({ + 'kbn-version': config.kbnVersion, + }); - const siemJobIds = getSiemJobIdsFromGroupsData(data); + const siemJobIds = getSiemJobIdsFromGroupsData(data); - setSiemJobs(siemJobIds); + setSiemJobs(siemJobIds); + } catch (error) { + errorToToaster({ title: i18n.SIEM_JOB_FETCH_FAILURE, error, dispatchToaster }); + } } setLoading(false); }; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx index 7c0325cacd2e..57f4b626beab 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx @@ -24,6 +24,8 @@ import { ShowingCount } from './jobs_table/showing_count'; import { PopoverDescription } from './popover_description'; import { getConfigTemplatesToInstall, getJobsToDisplay, getJobsToInstall } from './helpers'; import { configTemplates, siemJobPrefix } from './config_templates'; +import { useStateToaster } from '../toasters'; +import { errorToToaster } from '../ml/api/error_to_toaster'; const PopoverContentsDiv = styled.div` max-width: 550px; @@ -89,6 +91,7 @@ export const MlPopover = React.memo(() => { const [isLoadingJobSummaryData, jobSummaryData] = useJobSummaryData([], refreshToggle); const [isCreatingJobs, setIsCreatingJobs] = useState(false); const [filterQuery, setFilterQuery] = useState(''); + const [, dispatchToaster] = useStateToaster(); const [, configuredIndexPattern] = useIndexPatterns(refreshToggle); const config = useContext(KibanaConfigContext); @@ -105,9 +108,17 @@ export const MlPopover = React.memo(() => { if (enable) { const startTime = Math.max(latestTimestampMs, maxStartTime); - await startDatafeeds([`datafeed-${jobName}`], headers, startTime); + try { + await startDatafeeds([`datafeed-${jobName}`], headers, startTime); + } catch (error) { + errorToToaster({ title: i18n.START_JOB_FAILURE, error, dispatchToaster }); + } } else { - await stopDatafeeds([`datafeed-${jobName}`], headers); + try { + await stopDatafeeds([`datafeed-${jobName}`], headers); + } catch (error) { + errorToToaster({ title: i18n.STOP_JOB_FAILURE, error, dispatchToaster }); + } } dispatch({ type: 'refresh' }); }; @@ -144,19 +155,24 @@ export const MlPopover = React.memo(() => { ) { const setupJobs = async () => { setIsCreatingJobs(true); - await Promise.all( - configTemplatesToInstall.map(configTemplate => { - return setupMlJob({ - configTemplate: configTemplate.name, - indexPatternName: configTemplate.defaultIndexPattern, - groups: ['siem'], - prefix: siemJobPrefix, - headers, - }); - }) - ); - setIsCreatingJobs(false); - dispatch({ type: 'refresh' }); + try { + await Promise.all( + configTemplatesToInstall.map(configTemplate => { + return setupMlJob({ + configTemplate: configTemplate.name, + indexPatternName: configTemplate.defaultIndexPattern, + groups: ['siem'], + prefix: siemJobPrefix, + headers, + }); + }) + ); + setIsCreatingJobs(false); + dispatch({ type: 'refresh' }); + } catch (error) { + errorToToaster({ title: i18n.CREATE_JOB_FAILURE, error, dispatchToaster }); + setIsCreatingJobs(false); + } }; setupJobs(); } diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts index 883ceed6e923..50d4895f3497 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts @@ -78,3 +78,21 @@ export const CREATE_CUSTOM_JOB = i18n.translate( defaultMessage: 'Create custom job', } ); + +export const START_JOB_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.errors.startJobFailureTitle', + { + defaultMessage: 'Start job failure', + } +); + +export const STOP_JOB_FAILURE = i18n.translate('xpack.siem.containers.errors.stopJobFailureTitle', { + defaultMessage: 'Stop job failure', +}); + +export const CREATE_JOB_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.errors.createJobFailureTitle', + { + defaultMessage: 'Create job failure', + } +);