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', + } +);