From 0f143a38c6d1f93c3beb263f2d7b3959bca2ceaa Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 14 Jul 2020 03:39:39 -0400 Subject: [PATCH 01/58] [Security Solution] Add hook for reading/writing resolver query params (#70809) * Move resolver query param logic into shared hook * Store document location in state * Rename documentLocation to resolverComponentInstanceID * Use undefined for initial resolverComponentID value * Update type for initial state of component id --- .../public/resolver/store/data/action.ts | 1 + .../public/resolver/store/data/reducer.ts | 2 + .../resolver/store/data/selectors.test.ts | 21 ++++-- .../public/resolver/store/data/selectors.ts | 7 ++ .../public/resolver/store/selectors.ts | 5 ++ .../public/resolver/types.ts | 1 + .../public/resolver/view/index.tsx | 12 +++- .../public/resolver/view/map.tsx | 8 ++- .../public/resolver/view/panel.tsx | 43 ++----------- .../view/panels/panel_content_utilities.tsx | 4 +- .../resolver/view/process_event_dot.tsx | 35 +--------- .../view/use_resolver_query_params.ts | 64 +++++++++++++++++++ .../view/use_state_syncing_actions.ts | 6 +- .../components/graph_overlay/index.tsx | 5 +- 14 files changed, 131 insertions(+), 83 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 0d2a6936b4873..b6edf68aa7dc2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -75,6 +75,7 @@ interface AppReceivedNewExternalProperties { * the `_id` of an ES document. This defines the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 19b743374b8ed..c43182ddbf835 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -11,6 +11,7 @@ import { ResolverAction } from '../actions'; const initialState: DataState = { relatedEvents: new Map(), relatedEventsReady: new Map(), + resolverComponentInstanceID: undefined, }; export const dataReducer: Reducer = (state = initialState, action) => { @@ -18,6 +19,7 @@ export const dataReducer: Reducer = (state = initialS const nextState: DataState = { ...state, databaseDocumentID: action.payload.databaseDocumentID, + resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 630dfe555548f..cf23596db6134 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -53,11 +53,12 @@ describe('data state', () => { describe('when there is a databaseDocumentID but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, ]; }); @@ -104,11 +105,12 @@ describe('data state', () => { }); describe('when there is a pending request for the current databaseDocumentID', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, { type: 'appRequestedResolverData', @@ -160,12 +162,17 @@ describe('data state', () => { describe('when there is a pending request for a different databaseDocumentID than the current one', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; + const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; + const resolverComponentInstanceID2 = 'resolverComponentInstanceID2'; beforeEach(() => { actions = [ // receive the document ID, this would cause the middleware to starts the request { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: firstDatabaseDocumentID }, + payload: { + databaseDocumentID: firstDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID1, + }, }, // this happens when the middleware starts the request { @@ -175,7 +182,10 @@ describe('data state', () => { // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: secondDatabaseDocumentID }, + payload: { + databaseDocumentID: secondDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID2, + }, }, ]; }); @@ -188,6 +198,9 @@ describe('data state', () => { it('should need to abort the request for the databaseDocumentID', () => { expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); }); + it('should use the correct location for the second resolver', () => { + expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); + }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 990b911e5dbd0..9f425217a8d3e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -41,6 +41,13 @@ export function isLoading(state: DataState): boolean { return state.pendingRequestDatabaseDocumentID !== undefined; } +/** + * A string for uniquely identifying the instance of resolver within the app. + */ +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +} + /** * If a request was made and it threw an error or returned a failure response code. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 6e512cfe13f62..64921d214cc1b 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -69,6 +69,11 @@ export const databaseDocumentIDToAbort = composeSelectors( dataSelectors.databaseDocumentIDToAbort ); +export const resolverComponentInstanceID = composeSelectors( + dataStateSelector, + dataSelectors.resolverComponentInstanceID +); + export const processAdjacencies = composeSelectors( dataStateSelector, dataSelectors.processAdjacencies diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 2025762a0605c..064634472bbbe 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -177,6 +177,7 @@ export interface DataState { * The id used for the pending request, if there is one. */ readonly pendingRequestDatabaseDocumentID?: string; + readonly resolverComponentInstanceID: string | undefined; /** * The parameters and response from the last successful request. diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 205180a40d62a..c1ffa42d02abb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -18,6 +18,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const Resolver = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -28,6 +29,11 @@ export const Resolver = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { const context = useKibana(); const store = useMemo(() => { @@ -40,7 +46,11 @@ export const Resolver = React.memo(function ({ */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 3fc62fc318284..000bf23c5f49d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -29,6 +29,7 @@ import { SideEffectContext } from './side_effect_context'; export const ResolverMap = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -39,12 +40,17 @@ export const ResolverMap = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { /** * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); const { processNodePositions, connectingEdgeLineSegments } = useSelector( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index f4fe4fe520c92..061531b82d935 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useCallback, useMemo, useContext, useLayoutEffect, useState } from 'react'; +import React, { memo, useMemo, useContext, useLayoutEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { useHistory, useLocation } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { EuiPanel } from '@elastic/eui'; import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; @@ -21,7 +18,7 @@ import { EventCountsForProcess } from './panels/panel_content_related_counts'; import { ProcessDetails } from './panels/panel_content_process_detail'; import { ProcessListWithCounts } from './panels/panel_content_process_list'; import { RelatedEventDetail } from './panels/panel_content_related_detail'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * The team decided to use this table to determine which breadcrumbs/view to display: @@ -39,14 +36,11 @@ import { CrumbInfo } from './panels/panel_content_utilities'; * @returns {JSX.Element} The "right" table content to show based on the query params as described above */ const PanelContent = memo(function PanelContent() { - const history = useHistory(); - const urlSearch = useLocation().search; const dispatch = useResolverDispatch(); const { timestamp } = useContext(SideEffectContext); - const queryParams: CrumbInfo = useMemo(() => { - return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) }; - }, [urlSearch]); + + const { pushToQueryParams, queryParams } = useResolverQueryParams(); const graphableProcesses = useSelector(selectors.graphableProcesses); const graphableProcessEntityIds = useMemo(() => { @@ -104,35 +98,6 @@ const PanelContent = memo(function PanelContent() { } }, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]); - /** - * This updates the breadcrumb nav and the panel view. It's supplied to each - * panel content view to allow them to dispatch transitions to each other. - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - // We probably don't want to nuke the user's history with a huge - // trail of these, thus `.replace` instead of `.push` - return history.replace(relativeURL); - }, - [history, urlSearch] - ); - const relatedEventStats = useSelector(selectors.relatedEventsStats); const { crumbId, crumbEvent } = queryParams; const relatedStatsForIdFromParams: ResolverNodeStats | undefined = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 374c4c94c7768..4dedafe55bb2c 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -27,8 +27,8 @@ const BetaHeader = styled(`header`)` * The two query parameters we read/write on to control which view the table presents: */ export interface CrumbInfo { - readonly crumbId: string; - readonly crumbEvent: string; + crumbId: string; + crumbEvent: string; } const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 6442735abc8cd..17e7d3df42931 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -10,9 +10,6 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; @@ -22,7 +19,7 @@ import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as selectors from '../store/selectors'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * A record of all known event types (in schema format) to translations @@ -403,35 +400,7 @@ const UnstyledProcessEventDot = React.memo( }); }, [dispatch, selfId]); - const history = useHistory(); - const urlSearch = history.location.search; - - /** - * This updates the breadcrumb nav, the table view - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - - return history.replace(relativeURL); - }, - [history, urlSearch] - ); + const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts new file mode 100644 index 0000000000000..70baef5fa88ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -0,0 +1,64 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import * as selectors from '../store/selectors'; +import { CrumbInfo } from './panels/panel_content_utilities'; + +export function useResolverQueryParams() { + /** + * This updates the breadcrumb nav and the panel view. It's supplied to each + * panel content view to allow them to dispatch transitions to each other. + */ + const history = useHistory(); + const urlSearch = useLocation().search; + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; + const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + [uniqueCrumbIdKey]: newCrumbs.crumbId, + [uniqueCrumbEventKey]: newCrumbs.crumbEvent, + }; + + // If either was passed in as empty, remove it from the record + if (newCrumbs.crumbId === '') { + delete crumbsToPass[uniqueCrumbIdKey]; + } + if (newCrumbs.crumbEvent === '') { + delete crumbsToPass[uniqueCrumbEventKey]; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + // We probably don't want to nuke the user's history with a huge + // trail of these, thus `.replace` instead of `.push` + return history.replace(relativeURL); + }, + [history, urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey] + ); + const queryParams: CrumbInfo = useMemo(() => { + const parsed = querystring.parse(urlSearch.slice(1)); + const crumbEvent = parsed[uniqueCrumbEventKey]; + const crumbId = parsed[uniqueCrumbIdKey]; + return { + crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, + crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + }; + }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + + return { + pushToQueryParams, + queryParams, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index b8ea2049f5c49..642a054e8c519 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -13,17 +13,19 @@ import { useResolverDispatch } from './use_resolver_dispatch'; */ export function useStateSyncingActions({ databaseDocumentID, + resolverComponentInstanceID, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }) { const dispatch = useResolverDispatch(); useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }); - }, [dispatch, databaseDocumentID]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index fd5e8bc2434f3..0b5b51d6f1fb2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -118,7 +118,10 @@ const GraphOverlayComponent = ({ - + Date: Tue, 14 Jul 2020 09:40:27 +0200 Subject: [PATCH 02/58] Fix ScopedHistory mock and adapt usages (#71404) * Fix mock and adapt usages * fix snapshots * add comment about forcecast * remove mock overrides --- .../public/application/scoped_history.mock.ts | 13 +++--- .../embeddable_state_transfer.test.ts | 42 ++++--------------- .../helpers/setup_environment.tsx | 7 ++-- .../account_management_app.test.ts | 4 +- .../access_agreement_app.test.ts | 4 +- .../logged_out/logged_out_app.test.ts | 4 +- .../authentication/login/login_app.test.ts | 4 +- .../authentication/logout/logout_app.test.ts | 4 +- .../overwritten_session_app.test.ts | 4 +- .../api_keys/api_keys_management_app.test.tsx | 3 +- .../edit_role_mapping_page.test.tsx | 3 +- .../role_mappings_grid_page.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 3 +- .../roles/edit_role/edit_role_page.test.tsx | 4 +- .../roles/roles_grid/roles_grid_page.test.tsx | 9 ++-- .../roles/roles_management_app.test.tsx | 4 +- .../users/edit_user/edit_user_page.test.tsx | 3 +- .../users/users_grid/users_grid_page.test.tsx | 2 +- .../users/users_management_app.test.tsx | 3 +- .../helpers/setup_environment.tsx | 7 ++-- .../edit_space/manage_space_page.test.tsx | 3 +- .../spaces_grid/spaces_grid_pages.test.tsx | 3 +- .../management/spaces_management_app.test.tsx | 3 +- .../actions_connectors_list.test.tsx | 11 +++-- .../components/alerts_list.test.tsx | 9 ++-- .../helpers/app_context.mock.tsx | 7 ++-- 26 files changed, 63 insertions(+), 102 deletions(-) diff --git a/src/core/public/application/scoped_history.mock.ts b/src/core/public/application/scoped_history.mock.ts index 41c72306a99f9..3b954313700f2 100644 --- a/src/core/public/application/scoped_history.mock.ts +++ b/src/core/public/application/scoped_history.mock.ts @@ -20,16 +20,16 @@ import { Location } from 'history'; import { ScopedHistory } from './scoped_history'; -type ScopedHistoryMock = jest.Mocked>; +export type ScopedHistoryMock = jest.Mocked; + const createMock = ({ pathname = '/', search = '', hash = '', key, state, - ...overrides -}: Partial = {}) => { - const mock: ScopedHistoryMock = { +}: Partial = {}) => { + const mock: jest.Mocked> = { block: jest.fn(), createHref: jest.fn(), createSubHistory: jest.fn(), @@ -39,7 +39,6 @@ const createMock = ({ listen: jest.fn(), push: jest.fn(), replace: jest.fn(), - ...overrides, action: 'PUSH', length: 1, location: { @@ -51,7 +50,9 @@ const createMock = ({ }, }; - return mock; + // jest.Mocked still expects private methods and properties to be present, even + // if not part of the public contract. + return mock as ScopedHistoryMock; }; export const scopedHistoryMock = { diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index b7dd95ccba32c..42adb9d770e8a 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -19,7 +19,7 @@ import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; import { EmbeddableStateTransfer } from '.'; -import { ApplicationStart, ScopedHistory } from '../../../../../core/public'; +import { ApplicationStart } from '../../../../../core/public'; function mockHistoryState(state: unknown) { return scopedHistoryMock.create({ state }); @@ -46,10 +46,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing originating app state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp }, appendToExistingState: true, @@ -74,10 +71,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing embeddable package state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { state: { type: 'coolestType', id: '150' }, appendToExistingState: true, @@ -90,40 +84,28 @@ describe('embeddable state transfer', () => { it('can fetch an incoming originating app state', async () => { const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); }); it('returns undefined with originating app state is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toBeUndefined(); }); it('can fetch an incoming embeddable package state', async () => { const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' }); }); it('returns undefined when embeddable package is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toBeUndefined(); }); @@ -135,10 +117,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] }); expect(historyMock.replace).toHaveBeenCalledWith( expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } }) @@ -152,10 +131,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage(); expect(historyMock.location.state).toEqual({ type: 'skisEmbeddable', diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index fa8c4f82c1b68..a5796c10f8d93 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,7 +6,6 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import React from 'react'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, @@ -35,10 +34,10 @@ const httpServiceSetupMock = new HttpService().setup({ fatalErrors: fatalErrorsServiceMock.createSetupContract(), }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); const appServices = { breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index bac98d5639755..37b97a8472310 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./account_management_page'); -import { AppMount, AppNavLinkStatus, ScopedHistory } from 'src/core/public'; +import { AppMount, AppNavLinkStatus } from 'src/core/public'; import { UserAPIClient } from '../management'; import { accountManagementApp } from './account_management_app'; @@ -54,7 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index add2db6a3c170..0e262e9089842 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./access_agreement_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { accessAgreementApp } from './access_agreement_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -48,7 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index f0c18a3f1408e..15d55136b405d 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./logged_out_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loggedOutApp } from './logged_out_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -46,7 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index b7119d179b0b6..a6e5a321ef6ec 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./login_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loginApp } from './login_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -51,7 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 279500d14f211..46b1083a2ed14 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { logoutApp } from './logout_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -52,7 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 96e72ead22990..0eed1382c270b 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./overwritten_session_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { overwrittenSessionApp } from './overwritten_session_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -53,7 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./overwritten_session_page') diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 5f07b14ee71ef..30c5f8a361b42 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -7,7 +7,6 @@ jest.mock('./api_keys_grid', () => ({ APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { apiKeysManagementApp } from './api_keys_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -37,7 +36,7 @@ describe('apiKeysManagementApp', () => { basePath: '/some-base-path', element: container, setBreadcrumbs, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index b4e755507f8c5..04dc9c6dfa950 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -12,7 +12,6 @@ import { findTestSubject } from 'test_utils/find_test_subject'; // This is not required for the tests to pass, but it rather suppresses lengthy // warnings in the console which adds unnecessary noise to the test output. import 'test_utils/stub_web_worker'; -import { ScopedHistory } from 'kibana/public'; import { EditRoleMappingPage } from '.'; import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components'; @@ -28,7 +27,7 @@ import { rolesAPIClientMock } from '../../roles/roles_api_client.mock'; import { RoleComboBox } from '../../role_combo_box'; describe('EditRoleMappingPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); let rolesAPI: PublicMethodsOf; beforeEach(() => { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx index fb81ddb641e1f..727d7bf56e9e2 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx @@ -24,7 +24,7 @@ describe('RoleMappingsGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); coreStart = coreMock.createStart(); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index c95d78f90f51a..e65310ba399ea 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_role_mapping', () => ({ EditRoleMappingPage: (props: any) => `Role Mapping Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { roleMappingsManagementApp } from './role_mappings_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -26,7 +25,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 43387d913e6fc..f6fe2f394fd36 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -8,7 +8,7 @@ import { ReactWrapper } from 'enzyme'; import React from 'react'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { Capabilities, ScopedHistory } from 'src/core/public'; +import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; @@ -187,7 +187,7 @@ function getProps({ docLinks: new DocumentationLinksService(docLinks), fatalErrors, uiCapabilities: buildUICapabilities(canManageSpaces), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }; } diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index d83d5ef3f6468..005eebbfbf3bb 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -16,7 +16,6 @@ import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/publi import { rolesAPIClientMock } from '../index.mock'; import { ReservedBadge, DisabledBadge } from '../../badges'; import { findTestSubject } from 'test_utils/find_test_subject'; -import { ScopedHistory } from 'kibana/public'; const mock403 = () => ({ body: { statusCode: 403 } }); @@ -42,12 +41,12 @@ const waitForRender = async ( describe('', () => { let apiClientMock: jest.Mocked>; - let history: ScopedHistory; + let history: ReturnType; beforeEach(() => { - history = (scopedHistoryMock.create({ - createHref: jest.fn((location) => location.pathname!), - }) as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); + history.createHref.mockImplementation((location) => location.pathname!); + apiClientMock = rolesAPIClientMock.create(); apiClientMock.getRoles.mockResolvedValue([ { diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index e7f38c86b045e..c45528399db99 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -14,8 +14,6 @@ jest.mock('./edit_role', () => ({ EditRolePage: (props: any) => `Role Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; - import { rolesManagementApp } from './roles_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -40,7 +38,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index 7ee33357b9af4..40ffc508f086b 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -5,7 +5,6 @@ */ import { act } from '@testing-library/react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { EditUserPage } from './edit_user_page'; import React from 'react'; @@ -104,7 +103,7 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { } describe('EditUserPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows reserved users to be viewed', async () => { const user = createUser('reserved_user'); diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index edce7409e28d5..df8fe8cee7699 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -22,7 +22,7 @@ describe('UsersGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); history.createHref = (location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 98906f560e6cb..06bd2eff6aa1e 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_user', () => ({ EditUserPage: (props: any) => `User Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { usersManagementApp } from './users_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -31,7 +30,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx index e3c0ab0be9bd2..2cfffb3572dde 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx @@ -9,7 +9,6 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; import { setUiMetricService, httpService } from '../../../public/application/services/http'; @@ -25,10 +24,10 @@ import { documentationLinksService } from '../../../public/application/services/ const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); export const services = { uiMetricService: new UiMetricService('snapshot_restore'), diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index b0103800d4105..b573848f0c84a 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -7,7 +7,6 @@ import { EuiButton, EuiLink, EuiSwitch } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal'; @@ -46,7 +45,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('ManageSpacePage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 1868823823a1a..607570eedc787 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, shallowWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { SpaceAvatar } from '../../space_avatar'; import { spacesManagerMock } from '../../spaces_manager/mocks'; @@ -54,7 +53,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('SpacesGridPage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 834bfb73d8f46..1e8520a2617dd 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -17,7 +17,6 @@ jest.mock('./edit_space', () => ({ }, })); -import { ScopedHistory } from 'src/core/public'; import { spacesManagementApp } from './spaces_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../src/core/public/mocks'; @@ -58,7 +57,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 40505ac3fe76c..23a7223f9c21b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -5,7 +5,6 @@ */ import * as React from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { ScopedHistory } from 'kibana/public'; import { ActionsConnectorsList } from './actions_connectors_list'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -68,7 +67,7 @@ describe('actions_connectors_list component empty', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, @@ -175,7 +174,7 @@ describe('actions_connectors_list component with items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -263,7 +262,7 @@ describe('actions_connectors_list component empty with show only capability', () 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -352,7 +351,7 @@ describe('actions_connectors_list with show only capability', () => { 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -453,7 +452,7 @@ describe('actions_connectors_list component with disabled items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index dc2c1f972a5db..69b0856297bb5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import * as React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -103,7 +102,7 @@ describe('alerts_list component empty', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -222,7 +221,7 @@ describe('alerts_list component with items', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -304,7 +303,7 @@ describe('alerts_list component empty with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -419,7 +418,7 @@ describe('alerts_list with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx index 142504ee163b7..3db3cf5c66011 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { of } from 'rxjs'; import { ComponentType } from 'enzyme'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'src/core/public'; import { docLinksServiceMock, uiSettingsServiceMock, @@ -31,10 +30,10 @@ class MockTimeBuckets { } } -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; -}; +}); export const mockContextValue = { licenseStatus$: of({ valid: true }), From 35fc222bdced50cbd2143d675ddeacfdd4e4f431 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 14 Jul 2020 09:43:39 +0200 Subject: [PATCH 03/58] adjust vislib bar opacity (#71421) --- .../vis_type_vislib/public/vislib/lib/layout/_layout.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss index 6d96fa39e7c34..96c72bd5956d2 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss +++ b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss @@ -304,11 +304,14 @@ .series > path, .series > rect { - fill-opacity: .8; stroke-opacity: 1; stroke-width: 0; } + .series > path { + fill-opacity: .8; + } + .blur_shape { // sass-lint:disable-block no-important opacity: .3 !important; From 831e427682303ee05be2c91c1de737184218e235 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 14 Jul 2020 10:57:51 +0200 Subject: [PATCH 04/58] [Security] Add Timeline improvements (#71506) --- .../cypress/tasks/timeline.ts | 3 ++ .../__snapshots__/providers.test.tsx.snap | 53 ++++++++++++++----- .../add_data_provider_popover.tsx | 33 ++++++++---- .../timeline/data_providers/providers.tsx | 27 ++++------ .../timelines/components/timeline/index.tsx | 4 +- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 37ce9094dc594..761fd2c1e6a0b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -27,6 +27,8 @@ import { import { drag, drop } from '../tasks/common'; +export const hostExistsQuery = 'host.name: *'; + export const addDescriptionToTimeline = (description: string) => { cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating'); @@ -77,6 +79,7 @@ export const openTimelineSettings = () => { }; export const populateTimeline = () => { + executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) .invoke('text') .then((strCount) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap index a227f39494b61..a86c99cbc094a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap @@ -9,10 +9,11 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - - + @@ -58,7 +59,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -106,7 +109,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -154,7 +159,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -202,7 +209,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -250,7 +259,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -298,7 +309,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -346,7 +359,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -394,7 +409,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -442,7 +459,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -490,7 +509,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -527,6 +548,10 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` ) + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index 8e1c02bad50a3..71cf81c00dc09 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiContextMenu, EuiText, EuiPopover, @@ -139,21 +140,33 @@ const AddDataProviderPopoverComponent: React.FC = ( [browserFields, handleDataProviderEdited, timelineId, timelineType] ); - const button = useMemo( - () => ( - { + if (timelineType === TimelineType.template) { + return ( + + {ADD_FIELD_LABEL} + + ); + } + + return ( + - {ADD_FIELD_LABEL} - - ), - [handleOpenPopover] - ); + {`+ ${ADD_FIELD_LABEL}`} + + ); + }, [handleOpenPopover, timelineType]); const content = useMemo(() => { if (timelineType === TimelineType.template) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index c9dd906cee59b..1142bbc214d74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -82,10 +82,10 @@ const Parens = styled.span` `} `; -const AndOrBadgeContainer = styled.div` - width: 121px; - display: flex; - justify-content: flex-end; +const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` + span { + visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; + } `; const LastAndOrBadgeInGroup = styled.div` @@ -113,10 +113,6 @@ const ParensContainer = styled(EuiFlexItem)` align-self: center; `; -const AddDataProviderContainer = styled.div` - padding-right: 9px; -`; - const getDataProviderValue = (dataProvider: DataProvidersAnd) => dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; @@ -152,15 +148,9 @@ export const Providers = React.memo( - {groupIndex === 0 ? ( - - - - ) : ( - - - - )} + + + {'('} @@ -300,6 +290,9 @@ export const Providers = React.memo( {')'} + {groupIndex === dataProviderGroups.length - 1 && ( + + )} ))} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 5265efc8109a4..c4d89fa29cb32 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -266,7 +266,9 @@ const makeMapStateToProps = () => { // return events on empty search const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) ? ' ' : kqlQueryTimeline; + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; return { columns, dataProviders, From 3374b2d3b041143f87b8af1d35beea9d5f7bd93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:05:48 +0100 Subject: [PATCH 05/58] [Observability] Change appLink passing the date range (#71259) * changing apm appLink * changing apm appLink * removing title from api * adding absolute and relative times * addressing pr comments * addressing pr comments * addressing pr comments * fixing TS issues * addressing pr comments Co-authored-by: Elastic Machine --- x-pack/plugins/apm/public/plugin.ts | 8 +-- ....test.ts => apm_overview_fetchers.test.ts} | 43 +++++++------- ..._dashboard.ts => apm_overview_fetchers.ts} | 24 ++++---- .../get_service_count.ts | 0 .../get_transaction_coordinates.ts | 0 .../has_data.ts | 0 .../apm/server/routes/create_apm_api.ts | 10 ++-- ...dashboard.ts => observability_overview.ts} | 14 ++--- .../metrics_overview_fetchers.test.ts.snap | 3 +- .../public/metrics_overview_fetchers.test.ts | 12 +++- .../infra/public/metrics_overview_fetchers.ts | 27 ++++----- .../public/utils/logs_overview_fetchers.ts | 23 +++----- .../components/app/section/alerts/index.tsx | 14 +++-- .../components/app/section/apm/index.test.tsx | 15 +++-- .../components/app/section/apm/index.tsx | 34 +++++++---- .../app/section/apm/mock_data/apm.mock.ts | 2 - .../components/app/section/index.test.tsx | 4 +- .../public/components/app/section/index.tsx | 24 ++++---- .../components/app/section/logs/index.tsx | 34 +++++++---- .../components/app/section/metrics/index.tsx | 32 +++++++---- .../components/app/section/uptime/index.tsx | 36 ++++++++---- .../observability/public/data_handler.test.ts | 11 +++- .../public/pages/overview/index.tsx | 57 ++++++++++--------- .../public/pages/overview/mock/apm.mock.ts | 2 - .../public/pages/overview/mock/logs.mock.ts | 2 - .../pages/overview/mock/metrics.mock.ts | 2 - .../public/pages/overview/mock/uptime.mock.ts | 2 - .../typings/fetch_overview_data/index.ts | 8 +-- .../observability/public/utils/date.ts | 10 ++-- .../public/apps/uptime_overview_fetcher.ts | 23 ++++---- 30 files changed, 255 insertions(+), 221 deletions(-) rename x-pack/plugins/apm/public/services/rest/{observability.dashboard.test.ts => apm_overview_fetchers.test.ts} (78%) rename x-pack/plugins/apm/public/services/rest/{observability_dashboard.ts => apm_overview_fetchers.ts} (70%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_service_count.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_transaction_coordinates.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/has_data.ts (100%) rename x-pack/plugins/apm/server/routes/{observability_dashboard.ts => observability_overview.ts} (74%) diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 6e3a29d9f3dbc..f264ae6cd9852 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -39,9 +39,9 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { createStaticIndexPattern } from './services/rest/index_pattern'; import { - fetchLandingPageData, + fetchOverviewPageData, hasData, -} from './services/rest/observability_dashboard'; +} from './services/rest/apm_overview_fetchers'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -81,9 +81,7 @@ export class ApmPlugin implements Plugin { if (plugins.observability) { plugins.observability.dashboard.register({ appName: 'apm', - fetchData: async (params) => { - return fetchLandingPageData(params); - }, + fetchData: fetchOverviewPageData, hasData, }); } diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts similarity index 78% rename from x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts index fd407a8bf72ad..8b3ed38e25319 100644 --- a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts @@ -4,11 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchLandingPageData, hasData } from './observability_dashboard'; +import moment from 'moment'; +import { fetchOverviewPageData, hasData } from './apm_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; describe('Observability dashboard data', () => { const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const params = { + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T14:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, + bucketSize: '600s', + }; afterEach(() => { callApmApiMock.mockClear(); }); @@ -25,7 +37,7 @@ describe('Observability dashboard data', () => { }); }); - describe('fetchLandingPageData', () => { + describe('fetchOverviewPageData', () => { it('returns APM data with series and stats', async () => { callApmApiMock.mockImplementation(() => Promise.resolve({ @@ -37,14 +49,9 @@ describe('Observability dashboard data', () => { ], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -73,14 +80,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -105,14 +107,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts similarity index 70% rename from x-pack/plugins/apm/public/services/rest/observability_dashboard.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts index 409cec8b9ce10..78f3a0a0aaa80 100644 --- a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import { ApmFetchDataResponse, @@ -12,23 +11,26 @@ import { } from '../../../../observability/public'; import { callApmApi } from './createCallApmApi'; -export const fetchLandingPageData = async ({ - startTime, - endTime, +export const fetchOverviewPageData = async ({ + absoluteTime, + relativeTime, bucketSize, }: FetchDataParams): Promise => { const data = await callApmApi({ - pathname: '/api/apm/observability_dashboard', - params: { query: { start: startTime, end: endTime, bucketSize } }, + pathname: '/api/apm/observability_overview', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + bucketSize, + }, + }, }); const { serviceCount, transactionCoordinates } = data; return { - title: i18n.translate('xpack.apm.observabilityDashboard.title', { - defaultMessage: 'APM', - }), - appLink: '/app/apm', + appLink: `/app/apm#/services?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, stats: { services: { type: 'number', @@ -54,6 +56,6 @@ export const fetchLandingPageData = async ({ export async function hasData() { return await callApmApi({ - pathname: '/api/apm/observability_dashboard/has_data', + pathname: '/api/apm/observability_overview/has_data', }); } diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts rename to x-pack/plugins/apm/server/lib/observability_overview/has_data.ts diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 513c44904683e..0a4295fea3997 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,9 +79,9 @@ import { rumServicesRoute, } from './rum_client'; import { - observabilityDashboardHasDataRoute, - observabilityDashboardDataRoute, -} from './observability_dashboard'; + observabilityOverviewHasDataRoute, + observabilityOverviewRoute, +} from './observability_overview'; import { anomalyDetectionJobsRoute, createAnomalyDetectionJobsRoute, @@ -176,8 +176,8 @@ const createApmApi = () => { .add(rumServicesRoute) // Observability dashboard - .add(observabilityDashboardHasDataRoute) - .add(observabilityDashboardDataRoute) + .add(observabilityOverviewHasDataRoute) + .add(observabilityOverviewRoute) // Anomaly detection .add(anomalyDetectionJobsRoute) diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts similarity index 74% rename from x-pack/plugins/apm/server/routes/observability_dashboard.ts rename to x-pack/plugins/apm/server/routes/observability_overview.ts index 10c74295fe3e4..d5bb3b49c2f4c 100644 --- a/x-pack/plugins/apm/server/routes/observability_dashboard.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -5,22 +5,22 @@ */ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; -import { hasData } from '../lib/observability_dashboard/has_data'; +import { getServiceCount } from '../lib/observability_overview/get_service_count'; +import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; +import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; -import { getServiceCount } from '../lib/observability_dashboard/get_service_count'; -import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates'; -export const observabilityDashboardHasDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard/has_data', +export const observabilityOverviewHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_data', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); return await hasData({ setup }); }, })); -export const observabilityDashboardDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard', +export const observabilityOverviewRoute = createRoute(() => ({ + path: '/api/apm/observability_overview', params: { query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }, diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap index 4680414493a2c..d71e1feb575e4 100644 --- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap +++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap @@ -2,7 +2,7 @@ exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = ` Object { - "appLink": "/app/metrics", + "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)", "series": Object { "inboundTraffic": Object { "coordinates": Array [ @@ -203,6 +203,5 @@ Object { "value": 3, }, }, - "title": "Metrics", } `; diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 24c51598ad257..88bc426e9a0f7 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -53,12 +53,18 @@ describe('Metrics UI Observability Homepage Functions', () => { const { core, mockedGetStartServices } = setup(); core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE); const fetchData = createMetricsFetchData(mockedGetStartServices); - const endTime = moment(); + const endTime = moment('2020-07-02T13:25:11.629Z'); const startTime = endTime.clone().subtract(1, 'h'); const bucketSize = '300s'; const response = await fetchData({ - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), + absoluteTime: { + start: startTime.valueOf(), + end: endTime.valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize, }); expect(core.http.post).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 25b334d03c4f7..4eaf903e17608 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; -import { sum, isFinite, isNumber } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { MetricsFetchDataResponse, FetchDataParams } from '../../observability/public'; +import { isFinite, isNumber, sum } from 'lodash'; +import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public'; import { - SnapshotRequest, SnapshotMetricInput, SnapshotNode, SnapshotNodeResponse, + SnapshotRequest, } from '../common/http_api/snapshot_api'; import { SnapshotMetricType } from '../common/inventory_models/types'; import { InfraClientCoreSetup } from './types'; @@ -77,13 +75,12 @@ export const combineNodeTimeseriesBy = ( export const createMetricsFetchData = ( getStartServices: InfraClientCoreSetup['getStartServices'] -) => async ({ - startTime, - endTime, - bucketSize, -}: FetchDataParams): Promise => { +) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => { const [coreServices] = await getStartServices(); const { http } = coreServices; + + const { start, end } = absoluteTime; + const snapshotRequest: SnapshotRequest = { sourceId: 'default', metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[], @@ -91,8 +88,8 @@ export const createMetricsFetchData = ( nodeType: 'host', includeTimeseries: true, timerange: { - from: moment(startTime).valueOf(), - to: moment(endTime).valueOf(), + from: start, + to: end, interval: bucketSize, forceInterval: true, ignoreLookback: true, @@ -102,12 +99,8 @@ export const createMetricsFetchData = ( const results = await http.post('/api/metrics/snapshot', { body: JSON.stringify(snapshotRequest), }); - return { - title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', { - defaultMessage: 'Metrics', - }), - appLink: '/app/metrics', + appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`, stats: { hosts: { type: 'number', diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 5a0a996287959..53f7e00a3354c 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -5,18 +5,17 @@ */ import { encode } from 'rison-node'; -import { i18n } from '@kbn/i18n'; import { SearchResponse } from 'src/plugins/data/public'; -import { DEFAULT_SOURCE_ID } from '../../common/constants'; -import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; import { FetchData, - LogsFetchDataResponse, - HasData, FetchDataParams, + HasData, + LogsFetchDataResponse, } from '../../../observability/public'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; interface StatsAggregation { buckets: Array<{ key: string; doc_count: number }>; @@ -69,15 +68,11 @@ export function getLogsOverviewDataFetcher( data ); - const timeSpanInMinutes = - (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); + const timeSpanInMinutes = (params.absoluteTime.end - params.absoluteTime.start) / (1000 * 60); return { - title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { - defaultMessage: 'Logs', - }), - appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( - params.startTime + appLink: `/app/logs/stream?logPosition=(end:${encode(params.relativeTime.end)},start:${encode( + params.relativeTime.start )})`, stats: normalizeStats(stats, timeSpanInMinutes), series: normalizeSeries(series), @@ -122,8 +117,8 @@ function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { return { range: { [logParams.timestampField]: { - gt: params.startTime, - lte: params.endTime, + gt: new Date(params.absoluteTime.start).toISOString(), + lte: new Date(params.absoluteTime.end).toISOString(), format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index 4c80195d33ace..c0dc67b3373b1 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -44,12 +44,16 @@ export const AlertsSection = ({ alerts }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d4b8236e0ef49..7b9d7276dd1c5 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -8,6 +8,7 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; +import moment from 'moment'; describe('APMSection', () => { it('renders with transaction series and stats', () => { @@ -18,8 +19,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByTestId } = render( ); @@ -38,8 +42,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByText, getByTestId } = render( ); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 697d4adfa0b75..dce80ed324456 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -21,8 +21,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,20 +30,25 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { +export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const { title = 'APM', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -53,8 +58,15 @@ export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts index 5857021b1537f..edc236c714d32 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts +++ b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts @@ -7,8 +7,6 @@ import { ApmFetchDataResponse } from '../../../../../typings'; export const response: ApmFetchDataResponse = { - title: 'APM', - appLink: '/app/apm', stats: { services: { value: 11, type: 'number' }, diff --git a/x-pack/plugins/observability/public/components/app/section/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/index.test.tsx index 49cb175d0c094..708a5e468dc7c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.test.tsx @@ -20,13 +20,13 @@ describe('SectionContainer', () => { }); it('renders section with app link', () => { const component = render( - +
I am a very nice component
); expect(component.getByText('I am a very nice component')).toBeInTheDocument(); expect(component.getByText('Foo')).toBeInTheDocument(); - expect(component.getByText('View in app')).toBeInTheDocument(); + expect(component.getByText('foo')).toBeInTheDocument(); }); it('renders section with error', () => { const component = render( diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index 3556e8c01ab30..9ba524259ea1c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -4,21 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; import { ErrorPanel } from './error_panel'; import { usePluginContext } from '../../../hooks/use_plugin_context'; +interface AppLink { + label: string; + href?: string; +} + interface Props { title: string; hasError: boolean; children: React.ReactNode; - minHeight?: number; - appLink?: string; - appLinkName?: string; + appLink?: AppLink; } -export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => { +export const SectionContainer = ({ title, appLink, children, hasError }: Props) => { const { core } = usePluginContext(); return ( } extraAction={ - appLink && ( - - - {appLinkName - ? appLinkName - : i18n.translate('xpack.observability.chart.viewInAppLabel', { - defaultMessage: 'View in app', - })} - + appLink?.href && ( + + {appLink.label} ) } diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index f3ba2ef6fa83a..9b232ea33cbfb 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -25,8 +25,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,21 +45,26 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); - const { title, appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const colorsPerItem = getColorPerItem(series); @@ -67,8 +72,15 @@ export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 6276e1ba1baca..9e5fdadaf4e5f 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -18,8 +18,8 @@ import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,17 +46,23 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); + + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Metrics', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const cpuColor = theme.eui.euiColorVis7; const memoryColor = theme.eui.euiColorVis0; @@ -65,9 +71,15 @@ export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 1f8ca6e61f132..73a566460a593 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -30,37 +30,49 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } -export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => { +export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); + + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); const formatter = niceTimeFormatter([min, max]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Uptime', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const downColor = theme.eui.euiColorVis2; const upColor = theme.eui.euiColorLightShade; return ( diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 71c2c942239fd..7170ffe1486dc 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { registerDataHandler, getDataHandler } from './data_handler'; +import moment from 'moment'; const params = { - startTime: '0', - endTime: '1', + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T13:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize: '10s', }; diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3674e69ab5702..088fab032d930 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import moment from 'moment'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; @@ -23,7 +22,7 @@ import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_sett import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getParsedDate } from '../../utils/date'; +import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; @@ -33,13 +32,9 @@ interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) { - if (startTime && endTime) { - return getBucketSize({ - start: moment.utc(startTime).valueOf(), - end: moment.utc(endTime).valueOf(), - minInterval: '60s', - }); +function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { + if (start && end) { + return getBucketSize({ start, end, minInterval: '60s' }); } } @@ -62,16 +57,22 @@ export const OverviewPage = ({ routeParams }: Props) => { return ; } - const { - rangeFrom = timePickerTime.from, - rangeTo = timePickerTime.to, - refreshInterval = 10000, - refreshPaused = true, - } = routeParams.query; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const startTime = getParsedDate(rangeFrom); - const endTime = getParsedDate(rangeTo, { roundUp: true }); - const bucketSize = calculatetBucketSize({ startTime, endTime }); + const relativeTime = { + start: routeParams.query.rangeFrom ?? timePickerTime.from, + end: routeParams.query.rangeTo ?? timePickerTime.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start), + end: getAbsoluteTime(relativeTime.end, { roundUp: true }), + }; + + const bucketSize = calculatetBucketSize({ + start: absoluteTime.start, + end: absoluteTime.end, + }); const appEmptySections = getEmptySections({ core }).filter(({ id }) => { if (id === 'alert') { @@ -93,8 +94,8 @@ export const OverviewPage = ({ routeParams }: Props) => { @@ -116,8 +117,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_logs && ( @@ -125,8 +126,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_metrics && ( @@ -134,8 +135,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.apm && ( @@ -143,8 +144,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.uptime && ( diff --git a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts index 7303b78cc0132..6a0e1a64aa115 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts @@ -10,7 +10,6 @@ export const fetchApmData: FetchData = () => { }; const response: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { @@ -607,7 +606,6 @@ const response: ApmFetchDataResponse = { }; export const emptyResponse: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { diff --git a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts index 5bea1fbf19ace..8d1fb4d59c2cc 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts @@ -11,7 +11,6 @@ export const fetchLogsData: FetchData = () => { }; const response: LogsFetchDataResponse = { - title: 'Logs', appLink: "/app/logs/stream?logPosition=(end:'2020-06-30T21:30:00.000Z',start:'2020-06-27T22:00:00.000Z')", stats: { @@ -2319,7 +2318,6 @@ const response: LogsFetchDataResponse = { }; export const emptyResponse: LogsFetchDataResponse = { - title: 'Logs', appLink: '/app/logs', stats: {}, series: {}, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts index 37233b4f6342c..d5a7992ceabd8 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts @@ -11,7 +11,6 @@ export const fetchMetricsData: FetchData = () => { }; const response: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 11, type: 'number' }, @@ -113,7 +112,6 @@ const response: MetricsFetchDataResponse = { }; export const emptyResponse: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 0, type: 'number' }, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts index ab5874f8bfcd4..c4fa09ceb11f7 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts @@ -10,7 +10,6 @@ export const fetchUptimeData: FetchData = () => { }; const response: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { @@ -1191,7 +1190,6 @@ const response: UptimeFetchDataResponse = { }; export const emptyResponse: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 2dafd70896cc5..a3d7308ff9e4a 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -21,11 +21,8 @@ export interface Series { } export interface FetchDataParams { - // The start timestamp in milliseconds of the queried time interval - startTime: string; - // The end timestamp in milliseconds of the queried time interval - endTime: string; - // The aggregation bucket size in milliseconds if applicable to the data source + absoluteTime: { start: number; end: number }; + relativeTime: { start: string; end: string }; bucketSize: string; } @@ -41,7 +38,6 @@ export interface DataHandler { } export interface FetchDataResponse { - title: string; appLink: string; } diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts index fc0bbdae20cb9..bdc89ad6e8fc0 100644 --- a/x-pack/plugins/observability/public/utils/date.ts +++ b/x-pack/plugins/observability/public/utils/date.ts @@ -5,11 +5,9 @@ */ import datemath from '@elastic/datemath'; -export function getParsedDate(range?: string, opts = {}) { - if (range) { - const parsed = datemath.parse(range, opts); - if (parsed) { - return parsed.toISOString(); - } +export function getAbsoluteTime(range: string, opts = {}) { + const parsed = datemath.parse(range, opts); + if (parsed) { + return parsed.valueOf(); } } diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts index 89720b275c63d..d1e394dd4da6b 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -5,27 +5,24 @@ */ import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; -import { UptimeFetchDataResponse } from '../../../observability/public'; +import { UptimeFetchDataResponse, FetchDataParams } from '../../../observability/public'; export async function fetchUptimeOverviewData({ - startTime, - endTime, + absoluteTime, + relativeTime, bucketSize, -}: { - startTime: string; - endTime: string; - bucketSize: string; -}) { +}: FetchDataParams) { + const start = new Date(absoluteTime.start).toISOString(); + const end = new Date(absoluteTime.end).toISOString(); const snapshot = await fetchSnapshotCount({ - dateRangeStart: startTime, - dateRangeEnd: endTime, + dateRangeStart: start, + dateRangeEnd: end, }); - const pings = await fetchPingHistogram({ dateStart: startTime, dateEnd: endTime, bucketSize }); + const pings = await fetchPingHistogram({ dateStart: start, dateEnd: end, bucketSize }); const response: UptimeFetchDataResponse = { - title: 'Uptime', - appLink: '/app/uptime#/', + appLink: `/app/uptime#/?dateRangeStart=${relativeTime.start}&dateRangeEnd=${relativeTime.end}`, stats: { monitors: { type: 'number', From 90f233b5ebf774c887fc6f28249bd7770a61649f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:20:12 +0100 Subject: [PATCH 06/58] [APM] Use status_code field to calculate error rate (#71109) * calculating error rate based on status code * fixing unit test * addressing pr comments * adding erroneous transactions rate * adding erroneous transactions rate * adding error rate to detail page * fixing i18n Co-authored-by: Elastic Machine --- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 1 + .../ErrorGroupDetails/Distribution/index.tsx | 2 + .../app/ErrorGroupDetails/index.tsx | 37 +++--- .../app/ErrorGroupOverview/index.tsx | 35 ++---- .../app/TransactionDetails/index.tsx | 11 +- .../app/TransactionOverview/index.tsx | 11 +- .../TransactionBreakdownHeader.tsx | 50 -------- .../shared/TransactionBreakdown/index.tsx | 51 ++++---- .../index.tsx | 34 +++--- .../shared/charts/Histogram/index.js | 7 +- .../apm/server/lib/errors/get_error_rate.ts | 109 ------------------ .../lib/transaction_groups/get_error_rate.ts | 86 ++++++++++++++ .../apm/server/routes/create_apm_api.ts | 4 +- x-pack/plugins/apm/server/routes/errors.ts | 24 ---- .../apm/server/routes/transaction_groups.ts | 30 +++++ .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 18 files changed, 219 insertions(+), 283 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx rename x-pack/plugins/apm/public/components/shared/charts/{ErrorRateChart => ErroneousTransactionsRateChart}/index.tsx (79%) delete mode 100644 x-pack/plugins/apm/server/lib/errors/get_error_rate.ts create mode 100644 x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 06ca3145bfce9..f7f2836745384 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -38,6 +38,8 @@ exports[`Error HOST_NAME 1`] = `"my hostname"`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -182,6 +184,8 @@ exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -326,6 +330,8 @@ exports[`Transaction HOST_NAME 1`] = `"my hostname"`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; +exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`; + exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index a5a42ccbb9a21..d8d3827909b07 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -24,6 +24,7 @@ export const AGENT_VERSION = 'agent.version'; export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; +export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'; export const USER_ID = 'user.id'; export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 3cd04ee032e56..aa95918939dfa 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -12,6 +12,7 @@ import d3 from 'd3'; import { scaleUtc } from 'd3-scale'; import { mean } from 'lodash'; import React from 'react'; +import { px } from '../../../../style/variables'; import { asRelativeDateTimeRange } from '../../../../utils/formatters'; import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; // @ts-ignore @@ -88,6 +89,7 @@ export function ErrorDistribution({ distribution, title }: Props) { {title} bucket.x} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index b765dc42ede64..31f299f94bc26 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -16,18 +16,16 @@ import { import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/useFetcher'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; import { ApmHeader } from '../../shared/ApmHeader'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../observability/public'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const Titles = styled.div` margin-bottom: ${px(units.plus)}; @@ -181,24 +179,15 @@ export function ErrorGroupDetails() { )} - - - - - - - - - - + {showDetails && ( diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index 73474208e26c0..b9a28c1c1841f 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -18,11 +18,9 @@ import { PROJECTION } from '../../../../common/projections/typings'; import { useFetcher } from '../../../hooks/useFetcher'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const ErrorGroupOverview: React.FC = () => { const { urlParams, uiFilters } = useUrlParams(); @@ -99,28 +97,17 @@ const ErrorGroupOverview: React.FC = () => {
- - - - - - - - - - - - - - + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index c56b7b9aaa720..c4d5be5874215 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import React, { useMemo } from 'react'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; import { useWaterfall } from '../../../hooks/useWaterfall'; @@ -29,6 +30,7 @@ import { useTrackPageview } from '../../../../../observability/public'; import { PROJECTION } from '../../../../common/projections/typings'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { HeightRetainer } from '../../shared/HeightRetainer'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; export function TransactionDetails() { const location = useLocation(); @@ -84,7 +86,14 @@ export function TransactionDetails() { - + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 4ceeec8c50221..98702fe3686ff 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -19,10 +19,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { first } from 'lodash'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; import { TransactionList } from './List'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; @@ -125,7 +127,14 @@ export function TransactionOverview() { - + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx deleted file mode 100644 index 3a0fb3dd17eec..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const TransactionBreakdownHeader: React.FC<{ - showChart: boolean; - onToggleClick: () => void; -}> = ({ showChart, onToggleClick }) => { - return ( - - - -

- {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', - })} -

-
-
- - onToggleClick()} - > - {showChart - ? i18n.translate('xpack.apm.transactionBreakdown.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('xpack.apm.transactionBreakdown.showChart', { - defaultMessage: 'Show chart', - })} - - -
- ); -}; - -export { TransactionBreakdownHeader }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 75ae4e44cfede..51cad6bc65a85 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -3,58 +3,51 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownHeader } from './TransactionBreakdownHeader'; -import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { useUiTracker } from '../../../../../observability/public'; +import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { defaultMessage: 'No data within this time range.', }); -const TransactionBreakdown: React.FC<{ - initialIsOpen?: boolean; -}> = ({ initialIsOpen }) => { - const [showChart, setShowChart] = useState(!!initialIsOpen); +const TransactionBreakdown = () => { const { data, status } = useTransactionBreakdown(); - const trackApmEvent = useUiTracker({ app: 'apm' }); const { kpis, timeseries } = data; const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; - const showEmptyMessage = noHits && !showChart; return ( - { - setShowChart(!showChart); - if (showChart) { - trackApmEvent({ metric: 'hide_breakdown_chart' }); - } else { - trackApmEvent({ metric: 'show_breakdown_chart' }); - } - }} - /> + +

+ {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { + defaultMessage: 'Time spent by span type', + })} +

+
- {showEmptyMessage ? ( + {noHits ? ( {emptyMessage} ) : ( )} - {showChart ? ( - - - - ) : null} + + +
); diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx similarity index 79% rename from x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index de60441f4faa0..f87be32b43fc1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -8,11 +8,11 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import React, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; import { useChartsSync } from '../../../../hooks/useChartsSync'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { unit } from '../../../../style/variables'; import { asPercent } from '../../../../utils/formatters'; // @ts-ignore import CustomPlot from '../CustomPlot'; @@ -21,15 +21,23 @@ const tickFormatY = (y?: number) => { return asPercent(y || 0, 1); }; -export const ErrorRateChart = () => { +export const ErroneousTransactionsRateChart = () => { const { urlParams, uiFilters } = useUrlParams(); const syncedChartsProps = useChartsSync(); - const { serviceName, start, end, errorGroupId } = urlParams; - const { data: errorRateData } = useFetcher(() => { + const { + serviceName, + start, + end, + transactionType, + transactionName, + } = urlParams; + + const { data } = useFetcher(() => { if (serviceName && start && end) { return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/rate', + pathname: + '/api/apm/services/{serviceName}/transaction_groups/error_rate', params: { path: { serviceName, @@ -37,13 +45,14 @@ export const ErrorRateChart = () => { query: { start, end, + transactionType, + transactionName, uiFilters: JSON.stringify(uiFilters), - groupId: errorGroupId, }, }, }); } - }, [serviceName, start, end, uiFilters, errorGroupId]); + }, [serviceName, start, end, uiFilters, transactionType, transactionName]); const combinedOnHover = useCallback( (hoverX: number) => { @@ -52,20 +61,20 @@ export const ErrorRateChart = () => { [syncedChartsProps] ); - const errorRates = errorRateData?.errorRates || []; + const errorRates = data?.erroneousTransactionsRate || []; return ( - <> + {i18n.translate('xpack.apm.errorRateChart.title', { - defaultMessage: 'Error Rate', + defaultMessage: 'Transaction error rate', })} { formatTooltipValue={({ y }: { y?: number }) => Number.isFinite(y) ? tickFormatY(y) : 'N/A' } - height={unit * 10} /> - + ); }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js index 002ff19d0d1df..3b2109d68c613 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js @@ -103,6 +103,7 @@ export class HistogramInner extends PureComponent { tooltipHeader, verticalLineHover, width: XY_WIDTH, + height, legends, } = this.props; const { hoveredBucket } = this.state; @@ -181,7 +182,7 @@ export class HistogramInner extends PureComponent { ); return ( -
+
{noHits ? ( <>{emptyStateChart} @@ -250,7 +251,7 @@ export class HistogramInner extends PureComponent { { return { @@ -297,6 +298,7 @@ HistogramInner.propTypes = { tooltipHeader: PropTypes.func, verticalLineHover: PropTypes.func, width: PropTypes.number.isRequired, + height: PropTypes.number, xType: PropTypes.string, legends: PropTypes.array, noHits: PropTypes.bool, @@ -311,6 +313,7 @@ HistogramInner.defaultProps = { verticalLineHover: () => null, xType: 'linear', noHits: false, + height: XY_HEIGHT, }; export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts b/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts deleted file mode 100644 index e91d3953942d9..0000000000000 --- a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - ERROR_GROUP_ID, - PROCESSOR_EVENT, - SERVICE_NAME, -} from '../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { getMetricsDateHistogramParams } from '../helpers/metrics'; -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../helpers/setup_request'; -import { rangeFilter } from '../../../common/utils/range_filter'; - -export async function getErrorRate({ - serviceName, - groupId, - setup, -}: { - serviceName: string; - groupId?: string; - setup: Setup & SetupTimeRange & SetupUIFilters; -}) { - const { start, end, uiFiltersES, client, indices } = setup; - - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES, - ]; - - const aggs = { - response_times: { - date_histogram: getMetricsDateHistogramParams(start, end), - }, - }; - - const getTransactionBucketAggregation = async () => { - const resp = await client.search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ], - }, - }, - aggs, - }, - }); - return { - totalHits: resp.hits.total.value, - responseTimeBuckets: resp.aggregations?.response_times.buckets, - }; - }; - const getErrorBucketAggregation = async () => { - const groupIdFilter = groupId - ? [{ term: { [ERROR_GROUP_ID]: groupId } }] - : []; - const resp = await client.search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - ...groupIdFilter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, - ], - }, - }, - aggs, - }, - }); - return resp.aggregations?.response_times.buckets; - }; - - const [transactions, errorResponseTimeBuckets] = await Promise.all([ - getTransactionBucketAggregation(), - getErrorBucketAggregation(), - ]); - - const transactionCountByTimestamp: Record = {}; - if (transactions?.responseTimeBuckets) { - transactions.responseTimeBuckets.forEach((bucket) => { - transactionCountByTimestamp[bucket.key] = bucket.doc_count; - }); - } - - const errorRates = errorResponseTimeBuckets?.map((bucket) => { - const { key, doc_count: errorCount } = bucket; - const relativeRate = errorCount / transactionCountByTimestamp[key]; - return { x: key, y: relativeRate }; - }); - - return { - noHits: transactions?.totalHits === 0, - errorRates, - }; -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts new file mode 100644 index 0000000000000..5b66f7d7a45e7 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -0,0 +1,86 @@ +/* + * 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 { + PROCESSOR_EVENT, + HTTP_RESPONSE_STATUS_CODE, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { getMetricsDateHistogramParams } from '../helpers/metrics'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; + +export async function getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, +}: { + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const { start, end, uiFiltersES, client, indices } = setup; + + const transactionNamefilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const transactionTypefilter = transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []; + + const filter = [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + { exists: { field: HTTP_RESPONSE_STATUS_CODE } }, + ...transactionNamefilter, + ...transactionTypefilter, + ...uiFiltersES, + ]; + + const params = { + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + total_transactions: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs: { + erroneous_transactions: { + filter: { range: { [HTTP_RESPONSE_STATUS_CODE]: { gte: 400 } } }, + }, + }, + }, + }, + }, + }; + + const resp = await client.search(params); + + const noHits = resp.hits.total.value === 0; + + const erroneousTransactionsRate = + resp.aggregations?.total_transactions.buckets.map( + ({ key, doc_count: totalTransactions, erroneous_transactions }) => { + const errornousTransactionsCount = + // @ts-ignore + erroneous_transactions.doc_count; + return { + x: key, + y: errornousTransactionsCount / totalTransactions, + }; + } + ) || []; + + return { noHits, erroneousTransactionsRate }; +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 0a4295fea3997..4e3aa6d4ebe1d 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -13,7 +13,6 @@ import { errorDistributionRoute, errorGroupsRoute, errorsRoute, - errorRateRoute, } from './errors'; import { serviceAgentNameRoute, @@ -49,6 +48,7 @@ import { transactionGroupsRoute, transactionGroupsAvgDurationByCountry, transactionGroupsAvgDurationByBrowser, + transactionGroupsErrorRateRoute, } from './transaction_groups'; import { errorGroupsLocalFiltersRoute, @@ -99,7 +99,6 @@ const createApmApi = () => { .add(errorDistributionRoute) .add(errorGroupsRoute) .add(errorsRoute) - .add(errorRateRoute) // Services .add(serviceAgentNameRoute) @@ -139,6 +138,7 @@ const createApmApi = () => { .add(transactionGroupsRoute) .add(transactionGroupsAvgDurationByBrowser) .add(transactionGroupsAvgDurationByCountry) + .add(transactionGroupsErrorRateRoute) // UI filters .add(errorGroupsLocalFiltersRoute) diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 97314a9a61661..1615550027d3c 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,7 +11,6 @@ import { getErrorGroup } from '../lib/errors/get_error_group'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getErrorRate } from '../lib/errors/get_error_rate'; export const errorsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/errors', @@ -81,26 +80,3 @@ export const errorDistributionRoute = createRoute(() => ({ return getErrorDistribution({ serviceName, groupId, setup }); }, })); - -export const errorRateRoute = createRoute(() => ({ - path: '/api/apm/services/{serviceName}/errors/rate', - params: { - path: t.type({ - serviceName: t.string, - }), - query: t.intersection([ - t.partial({ - groupId: t.string, - }), - uiFiltersRt, - rangeRt, - ]), - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; - const { serviceName } = params.path; - const { groupId } = params.query; - return getErrorRate({ serviceName, groupId, setup }); - }, -})); diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 3d939b04795c6..dca2fb1d9b295 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -14,6 +14,7 @@ import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getTransactionAvgDurationByBrowser } from '../lib/transactions/avg_duration_by_browser'; import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country'; +import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; import { UIFilters } from '../../typings/ui_filters'; export const transactionGroupsRoute = createRoute(() => ({ @@ -209,3 +210,32 @@ export const transactionGroupsAvgDurationByCountry = createRoute(() => ({ }); }, })); + +export const transactionGroupsErrorRateRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/error_rate', + params: { + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + uiFiltersRt, + rangeRt, + t.partial({ + transactionType: t.string, + transactionName: t.string, + }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + const { serviceName } = params.path; + const { transactionType, transactionName } = params.query; + return getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, + }); + }, +})); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ef95f5f9c09d8..5734056f36bd9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4481,9 +4481,7 @@ "xpack.apm.transactionActionMenu.viewInUptime": "ステータス", "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "サンプルドキュメントを表示", "xpack.apm.transactionBreakdown.chartTitle": "スパンタイプ別時間", - "xpack.apm.transactionBreakdown.hideChart": "グラフを非表示", "xpack.apm.transactionBreakdown.noData": "この時間範囲のデータがありません。", - "xpack.apm.transactionBreakdown.showChart": "グラフを表示", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} {errorCount, plural, one {件のエラー} other {件のエラー}}", "xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {1 件の関連エラーを表示} other {# 件の関連エラーを表示}}", "xpack.apm.transactionDetails.notFoundLabel": "トランザクションが見つかりませんでした。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 108fb4ba32046..823a787a11e5d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4485,9 +4485,7 @@ "xpack.apm.transactionActionMenu.viewInUptime": "状态", "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "查看样例文档", "xpack.apm.transactionBreakdown.chartTitle": "跨度类型花费的时间", - "xpack.apm.transactionBreakdown.hideChart": "隐藏图表", "xpack.apm.transactionBreakdown.noData": "此时间范围内没有数据。", - "xpack.apm.transactionBreakdown.showChart": "显示图表", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} 个 {errorCount, plural, one {错误} other {错误}}", "xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {查看 1 个相关错误} other {查看 # 个相关错误}}", "xpack.apm.transactionDetails.notFoundLabel": "未找到任何事务。", From 57144f9d274fd4dab740d3614904a493493cf9d5 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 14 Jul 2020 12:38:37 +0200 Subject: [PATCH 07/58] [ML] Functional tests - disable DFA creation and cloning tests --- x-pack/test/functional/apps/ml/data_frame_analytics/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index 0202c8431ce34..a2ac236a5ea27 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -6,7 +6,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('data frame analytics', function () { + // flaky tests + describe.skip('data frame analytics', function () { this.tags(['mlqa', 'skipFirefox']); loadTestFile(require.resolve('./outlier_detection_creation')); From 5ef8d3f5091ba3ae36c125a0196065d95743fd8d Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 14 Jul 2020 05:54:29 -0500 Subject: [PATCH 08/58] [Metrics UI] Remove UUID from Alert Instance IDs (#71335) * [Metrics UI] Use alertId instead of uuid for alertInstanceIds --- x-pack/plugins/alerts/README.md | 6 ++-- .../inventory_metric_threshold_executor.ts | 10 +++---- ...r_inventory_metric_threshold_alert_type.ts | 4 +-- .../metric_threshold_executor.test.ts | 29 ++++++++++--------- .../metric_threshold_executor.ts | 4 +-- .../register_metric_threshold_alert_type.ts | 4 +-- 6 files changed, 28 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 811478426a8d3..2f2ffb52e7e90 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -482,13 +482,15 @@ A schedule is structured such that the key specifies the format you wish to use We currently support the _Interval format_ which specifies the interval in seconds, minutes, hours or days at which the alert should execute. Example: `{ interval: "10s" }`, `{ interval: "5m" }`, `{ interval: "1h" }`, `{ interval: "1d" }`. -There are plans to support multiple other schedule formats in the near fuiture. +There are plans to support multiple other schedule formats in the near future. ## Alert instance factory **alertInstanceFactory(id)** -One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same id. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. +One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The `id` you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same `id`. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. + +Note that the `id` only needs to be unique **within the scope of a specific alert**, not unique across all alerts or alert types. For example, Alert 1 and Alert 2 can both create an alert instance with an `id` of `"a"` without conflicting with one another. But if Alert 1 creates 2 alert instances, then they must be differentiated with `id`s of `"a"` and `"b"`. This factory returns an instance of `AlertInstance`. The alert instance class has the following methods, note that we have removed the methods that you shouldn't touch. diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 1ef86d9e7eac4..0a3910f2c5d7c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -29,10 +29,10 @@ interface InventoryMetricThresholdParams { alertOnNoData?: boolean; } -export const createInventoryMetricThresholdExecutor = ( - libs: InfraBackendLibs, - alertId: string -) => async ({ services, params }: AlertExecutorOptions) => { +export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) => async ({ + services, + params, +}: AlertExecutorOptions) => { const { criteria, filterQuery, @@ -54,7 +54,7 @@ export const createInventoryMetricThresholdExecutor = ( const inventoryItems = Object.keys(first(results) as any); for (const item of inventoryItems) { - const alertInstance = services.alertInstanceFactory(`${item}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${item}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => result[item].shouldFire); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index d7c4165d5a870..85b38f48d9f22 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -5,8 +5,6 @@ */ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; -import uuid from 'uuid'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -43,7 +41,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], producer: 'metrics', - executor: curry(createInventoryMetricThresholdExecutor)(libs, uuid.v4()), + executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 003a6c3c20e98..9a46925a51762 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -24,7 +24,7 @@ let persistAlertInstances = false; // eslint-disable-line describe('The metric threshold alert type', () => { describe('querying the entire infrastructure', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[], sourceId: string = 'default') => executor({ services, @@ -120,8 +120,8 @@ describe('The metric threshold alert type', () => { ], }, }); - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); @@ -177,20 +177,20 @@ describe('The metric threshold alert type', () => { }, }); test('sends an alert when all criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID)).toBe(undefined); expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); @@ -198,7 +198,7 @@ describe('The metric threshold alert type', () => { expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends all criteria to the action context', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); const { action } = mostRecentAction(instanceID); const reasons = action.reason.split('\n'); @@ -212,7 +212,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the count aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -238,7 +238,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p99 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -264,7 +264,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p95 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -290,7 +290,7 @@ describe('The metric threshold alert type', () => { }); }); describe("querying a metric that hasn't reported data", () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (alertOnNoData: boolean) => executor({ services, @@ -319,9 +319,10 @@ describe('The metric threshold alert type', () => { }); // describe('querying a metric that later recovers', () => { - // const instanceID = '*::test'; + // const instanceID = '*'; // const execute = (threshold: number[]) => // executor({ + // // services, // params: { // criteria: [ @@ -379,7 +380,7 @@ const mockLibs: any = { configuration: createMockStaticConfiguration({}), }; -const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: { +const executor = createMetricThresholdExecutor(mockLibs) as (opts: { params: AlertExecutorOptions['params']; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index bc1cc24f65eeb..b4754a8624fd5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -17,7 +17,7 @@ import { import { AlertStates } from './types'; import { evaluateAlert } from './lib/evaluate_alert'; -export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => +export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => async function (options: AlertExecutorOptions) { const { services, params } = options; const { criteria } = params; @@ -36,7 +36,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s // Because each alert result has the same group definitions, just grap the groups from the first one. const groups = Object.keys(first(alertResults) as any); for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${group}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${group}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 02d9ca3e5f0c9..529a1d176c437 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; @@ -107,7 +105,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: curry(createMetricThresholdExecutor)(libs, uuid.v4()), + executor: createMetricThresholdExecutor(libs), actionVariables: { context: [ { name: 'group', description: groupActionVariableDescription }, From 6c4fc9ca206d77992f2056f209d3689935a70c71 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 14 Jul 2020 05:55:05 -0500 Subject: [PATCH 09/58] [Logs UI] Remove UUID from Alert Instances (#71340) * [Logs UI] Remove UUID from Alert Instances * Fix bad template string Co-authored-by: Elastic Machine --- .../infra/server/lib/alerting/common/utils.ts | 2 ++ .../evaluate_condition.ts | 5 ++-- .../log_threshold_executor.test.ts | 24 +++++++++---------- .../log_threshold/log_threshold_executor.ts | 22 +++++++---------- .../register_log_threshold_alert_type.ts | 5 +--- .../metric_threshold/lib/evaluate_alert.ts | 7 +++--- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts index 100260c499673..27eaeb8eee5ac 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -29,3 +29,5 @@ export const validateIsStringElasticsearchJSONFilter = (value: string) => { return errorMessage; } }; + +export const UNGROUPED_FACTORY_KEY = '*'; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 868ea5bfbffe1..c991e482a62e5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -20,6 +20,7 @@ import { parseFilterQuery } from '../../../utils/serialized_query'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; import { InfraSourceConfiguration } from '../../sources'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean | boolean[]; @@ -129,14 +130,14 @@ const getData = async ( const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': undefined }; + return { [UNGROUPED_FACTORY_KEY]: undefined }; } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index 4f1e81e0b2c40..940afd72f6c73 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -54,19 +54,19 @@ services.alertInstanceFactory.mockImplementation((instanceId: string) => { /* * Helper functions */ -function getAlertState(instanceId: string): AlertStates { - const alert = alertInstances.get(`${instanceId}-*`); +function getAlertState(): AlertStates { + const alert = alertInstances.get('*'); if (alert) { return alert.state.alertState; } else { - throw new Error('Could not find alert instance `' + instanceId + '`'); + throw new Error('Could not find alert instance'); } } /* * Executor instance (our test subject) */ -const executor = (createLogThresholdExecutor('test', libsMock) as unknown) as (opts: { +const executor = (createLogThresholdExecutor(libsMock) as unknown) as (opts: { params: LogDocumentCountAlertParams; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; @@ -109,30 +109,30 @@ describe('Ungrouped alerts', () => { describe('Comparators trigger alerts correctly', () => { it('does not alert when counts do not reach the threshold', async () => { await callExecutor([0, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([0, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT_OR_EQ, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); }); it('alerts when counts reach the threshold', async () => { await callExecutor([2, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.LT, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([2, Comparator.LT_OR_EQ, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a2fd01f859385..85bb18e199192 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -21,8 +21,8 @@ import { InfraBackendLibs } from '../../infra_types'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InfraSource } from '../../../../common/http_api/source_api'; import { decodeOrThrow } from '../../../../common/runtime_types'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; -const UNGROUPED_FACTORY_KEY = '*'; const COMPOSITE_GROUP_SIZE = 40; const checkValueAgainstComparatorMap: { @@ -34,7 +34,7 @@ const checkValueAgainstComparatorMap: { [Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b, }; -export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLibs) => +export const createLogThresholdExecutor = (libs: InfraBackendLibs) => async function ({ services, params }: AlertExecutorOptions) { const { alertInstanceFactory, savedObjectsClient, callCluster } = services; const { sources } = libs; @@ -42,7 +42,7 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); const indexPattern = sourceConfiguration.configuration.logAlias; - const alertInstance = alertInstanceFactory(alertId); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); try { const validatedParams = decodeOrThrow(LogDocumentCountAlertParamsRT)(params); @@ -60,15 +60,13 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi processGroupByResults( await getGroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } else { processUngroupedResults( await getUngroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } } catch (e) { @@ -83,12 +81,11 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const processUngroupedResults = ( results: UngroupedSearchQueryResponse, params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; - const alertInstance = alertInstanceFactory(`${alertId}-${UNGROUPED_FACTORY_KEY}`); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); const documentCount = results.hits.total.value; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { @@ -116,8 +113,7 @@ interface ReducedGroupByResults { const processGroupByResults = ( results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; @@ -128,7 +124,7 @@ const processGroupByResults = ( }, []); groupResults.forEach((group) => { - const alertInstance = alertInstanceFactory(`${alertId}-${group.name}`); + const alertInstance = alertInstanceFactory(group.name); const documentCount = group.documentCount; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index 43c298019b632..fbbb38da53929 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { PluginSetupContract } from '../../../../../alerts/server'; @@ -71,8 +70,6 @@ export async function registerLogThresholdAlertType( ); } - const alertUUID = uuid.v4(); - alertingPlugin.registerType({ id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, name: 'Log threshold', @@ -87,7 +84,7 @@ export async function registerLogThresholdAlertType( }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: createLogThresholdExecutor(alertUUID, libs), + executor: createLogThresholdExecutor(libs), actionVariables: { context: [ { name: 'matchingDocuments', description: documentCountActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index 7f6bf9551e2c1..d862f70c47cae 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -15,6 +15,7 @@ import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; import { DOCUMENT_COUNT_I18N } from '../../common/messages'; +import { UNGROUPED_FACTORY_KEY } from '../../common/utils'; import { MetricExpressionParams, Comparator, Aggregators } from '../types'; import { getElasticsearchMetricQuery } from './metric_query'; @@ -133,21 +134,21 @@ const getMetric: ( index, }); - return { '*': getValuesFromAggregations(result.aggregations, aggType) }; + return { [UNGROUPED_FACTORY_KEY]: getValuesFromAggregations(result.aggregations, aggType) }; } catch (e) { if (timeframe) { // This code should only ever be reached when previewing the alert, not executing it const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': NaN }; // Trigger an Error state + return { [UNGROUPED_FACTORY_KEY]: NaN }; // Trigger an Error state } }; From a4efa1ead01ace103dff56066c0b963b68118a2f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 14 Jul 2020 11:58:17 +0100 Subject: [PATCH 10/58] [test] Skips test preventing promotion of ES snapshot #71612 --- .../security_and_spaces/tests/create_rules_bulk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 52865e43be750..b59fd1b744e97 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,7 +29,8 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('create_rules_bulk', () => { + // Failing ES promotion: https://github.com/elastic/kibana/issues/71612 + describe.skip('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest From d8204643fe537b7e2d09301b9d36d853b4e92430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= Date: Tue, 14 Jul 2020 13:28:35 +0200 Subject: [PATCH 11/58] [Logs UI] Refine log entry row context button (#71260) Co-authored-by: Elastic Machine --- .../log_entry_context_menu.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx index adc1ce4d8c9fd..be140a810f164 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -6,7 +6,13 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { + EuiButton, + EuiIcon, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, +} from '@elastic/eui'; import { euiStyled } from '../../../../../observability/public'; import { LogEntryColumnContent } from './log_entry_column'; @@ -50,12 +56,15 @@ export const LogEntryContextMenu: React.FC = ({ const button = ( - + style={{ minWidth: 'auto' }} + > + + ); @@ -88,8 +97,5 @@ const AbsoluteWrapper = euiStyled.div` `; const ButtonWrapper = euiStyled.div` - background: ${(props) => props.theme.eui.euiColorPrimary}; - border-radius: 50%; - padding: 4px; - transform: translateY(-6px); + transform: translate(-6px, -6px); `; From 262e0754ff5b4be301b00992496fd9871deb9ed3 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 14 Jul 2020 13:37:36 +0200 Subject: [PATCH 12/58] [ML] Kibana API endpoint for histogram chart data (#70976) - Introduces dedicated Kibana API endpoints as part of ML and transform plugin API endpoints and moves the logic to query and transform the required data from client to server. - Adds support for sampling to retrieve the data for the field histograms. For now this is not configurable by the end user and is hard coded to 5000. This is to have a first iteration of this functionality in for 7.9 and protect users when querying large clusters. The button to enable the histogram charts now includes a tooltip that mentions the sampler. --- .../ml/common/constants/field_histograms.ts | 8 + .../components/data_grid/data_grid.tsx | 41 ++- .../application/components/data_grid/index.ts | 2 +- .../data_grid/use_column_chart.test.ts | 18 ++ .../components/data_grid/use_column_chart.tsx | 186 +----------- .../hooks/use_index_data.ts | 24 +- .../use_exploration_results.ts | 28 +- .../outlier_exploration/use_outlier_data.ts | 30 +- .../index_based/common/index.ts | 2 +- .../index_based/common/request.ts | 7 + .../index_based/data_loader/data_loader.ts | 33 ++- .../datavisualizer/index_based/page.tsx | 8 +- .../services/ml_api_service/index.ts | 29 +- .../models/data_visualizer/data_visualizer.ts | 267 +++++++++++++++++- .../ml/server/models/data_visualizer/index.ts | 2 +- .../ml/server/routes/data_visualizer.ts | 61 +++- .../routes/schemas/data_visualizer_schema.ts | 9 + x-pack/plugins/ml/server/shared.ts | 1 + .../transform/public/app/hooks/use_api.ts | 26 ++ .../public/app/hooks/use_index_data.ts | 15 +- .../transform/public/shared_imports.ts | 2 +- .../server/routes/api/field_histograms.ts | 50 ++++ .../transform/server/routes/api/schema.ts | 18 ++ .../plugins/transform/server/routes/index.ts | 2 + .../transform/server/shared_imports.ts | 7 + .../data_visualizer/get_field_histograms.ts | 122 ++++++++ .../outlier_detection_creation.ts | 22 ++ .../ml/data_frame_analytics_creation.ts | 52 ++++ 28 files changed, 822 insertions(+), 250 deletions(-) create mode 100644 x-pack/plugins/ml/common/constants/field_histograms.ts create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts create mode 100644 x-pack/plugins/transform/server/routes/api/field_histograms.ts create mode 100644 x-pack/plugins/transform/server/shared_imports.ts create mode 100644 x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts diff --git a/x-pack/plugins/ml/common/constants/field_histograms.ts b/x-pack/plugins/ml/common/constants/field_histograms.ts new file mode 100644 index 0000000000000..5c86c00ac666f --- /dev/null +++ b/x-pack/plugins/ml/common/constants/field_histograms.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 9af7a869e0e56..d4be2eab13d26 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -20,10 +20,13 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../common/constants/field_histograms'; + import { INDEX_STATUS } from '../../data_frame_analytics/common'; import { euiDataGridStyle, euiDataGridToolbarSettings } from './common'; @@ -193,21 +196,31 @@ export const DataGrid: FC = memo( ...(chartsButtonVisible ? { additionalControls: ( - - {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { - defaultMessage: 'Histogram charts', + + > + + {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { + defaultMessage: 'Histogram charts', + })} + + ), } : {}), diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 80bc6b861f742..4bbd3595e5a7e 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -12,7 +12,7 @@ export { showDataGridColumnChartErrorMessageToast, useRenderCellValue, } from './common'; -export { fetchChartsData, ChartData } from './use_column_chart'; +export { getFieldType, ChartData } from './use_column_chart'; export { useDataGrid } from './use_data_grid'; export { DataGrid } from './data_grid'; export { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts new file mode 100644 index 0000000000000..1b35ef238d09e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts @@ -0,0 +1,18 @@ +/* + * 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 { getFieldType } from './use_column_chart'; + +describe('getFieldType()', () => { + it('should return the Kibana field type for a given EUI data grid schema', () => { + expect(getFieldType('text')).toBe('string'); + expect(getFieldType('datetime')).toBe('date'); + expect(getFieldType('numeric')).toBe('number'); + expect(getFieldType('boolean')).toBe('boolean'); + expect(getFieldType('json')).toBe('object'); + expect(getFieldType('non-aggregatable')).toBe(undefined); + }); +}); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx index 6b207a999eb52..a762c44e243bf 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -16,8 +16,6 @@ import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; -import { stringHash } from '../../../../common/util/string_utils'; - import { NON_AGGREGATABLE } from './common'; export const hoveredRow$ = new BehaviorSubject(null); @@ -40,7 +38,7 @@ const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => } }; -const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { +export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { if (schema === NON_AGGREGATABLE) { return undefined; } @@ -67,188 +65,6 @@ const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | un return fieldType; }; -interface NumericColumnStats { - interval: number; - min: number; - max: number; -} -type NumericColumnStatsMap = Record; -const getAggIntervals = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const numericColumns = columnTypes.filter((cT) => { - const fieldType = getFieldType(cT.schema); - return fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE; - }); - - if (numericColumns.length === 0) { - return {}; - } - - const minMaxAggs = numericColumns.reduce((aggs, c) => { - const id = stringHash(c.id); - aggs[id] = { - stats: { - field: c.id, - }, - }; - return aggs; - }, {} as Record); - - const respStats = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: minMaxAggs, - size: 0, - }, - }); - - return Object.keys(respStats.aggregations).reduce((p, aggName) => { - const stats = [respStats.aggregations[aggName].min, respStats.aggregations[aggName].max]; - if (!stats.includes(null)) { - const delta = respStats.aggregations[aggName].max - respStats.aggregations[aggName].min; - - let aggInterval = 1; - - if (delta > MAX_CHART_COLUMNS) { - aggInterval = Math.round(delta / MAX_CHART_COLUMNS); - } - - if (delta <= 1) { - aggInterval = delta / MAX_CHART_COLUMNS; - } - - p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; - } - - return p; - }, {} as NumericColumnStatsMap); -}; - -interface AggHistogram { - histogram: { - field: string; - interval: number; - }; -} - -interface AggCardinality { - cardinality: { - field: string; - }; -} - -interface AggTerms { - terms: { - field: string; - size: number; - }; -} - -type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; - -export const fetchChartsData = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const aggIntervals = await getAggIntervals(indexPatternTitle, esSearch, query, columnTypes); - - const chartDataAggs = columnTypes.reduce((aggs, c) => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] !== undefined) { - aggs[`${id}_histogram`] = { - histogram: { - field: c.id, - interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, - }, - }; - } - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - if (fieldType === KBN_FIELD_TYPES.STRING) { - aggs[`${id}_cardinality`] = { - cardinality: { - field: c.id, - }, - }; - } - aggs[`${id}_terms`] = { - terms: { - field: c.id, - size: MAX_CHART_COLUMNS, - }, - }; - } - return aggs; - }, {} as Record); - - if (Object.keys(chartDataAggs).length === 0) { - return []; - } - - const respChartsData = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: chartDataAggs, - size: 0, - }, - }); - - const chartsData: ChartData[] = columnTypes.map( - (c): ChartData => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] === undefined) { - return { - type: 'numeric', - data: [], - interval: 0, - stats: [0, 0], - id: c.id, - }; - } - - return { - data: respChartsData.aggregations[`${id}_histogram`].buckets, - interval: aggIntervals[id].interval, - stats: [aggIntervals[id].min, aggIntervals[id].max], - type: 'numeric', - id: c.id, - }; - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - return { - type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', - cardinality: - fieldType === KBN_FIELD_TYPES.STRING - ? respChartsData.aggregations[`${id}_cardinality`].value - : 2, - data: respChartsData.aggregations[`${id}_terms`].buckets, - id: c.id, - }; - } - - return { - type: 'unsupported', - id: c.id, - }; - } - ); - - return chartsData; -}; - interface NumericDataItem { key: number; key_as_string?: string; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ee0e5c1955ead..2cecffc993257 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; + +import { DataLoader } from '../../../../datavisualizer/index_based/data_loader'; + import { - fetchChartsData, + getFieldType, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, showDataGridColumnChartErrorMessageToast, @@ -103,13 +106,20 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]); + const dataLoader = useMemo(() => new DataLoader(indexPattern, toastNotifications), [ + indexPattern, + ]); + const fetchColumnChartsData = async function () { try { - const columnChartsData = await fetchChartsData( - indexPattern.title, - ml.esSearch, - query, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + query ); dataGrid.setColumnCharts(columnChartsData); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index 796670f6a864d..98dd40986e32b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; @@ -12,16 +12,17 @@ import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { - fetchChartsData, getDataGridSchemasFromFieldTypes, + getFieldType, showDataGridColumnChartErrorMessageToast, useDataGrid, useRenderCellValue, UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; import { @@ -72,14 +73,23 @@ export const useExplorationResults = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined ? new DataLoader(indexPattern, toastNotifications) : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index beb6836bf801f..90294a09c0adc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { useColorRange, COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; import { - fetchChartsData, + getFieldType, getDataGridSchemasFromFieldTypes, showDataGridColumnChartErrorMessageToast, useDataGrid, @@ -24,7 +26,6 @@ import { UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; @@ -79,14 +80,25 @@ export const useOutlierData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined + ? new DataLoader(indexPattern, getToastNotifications()) + : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts index 5618f701e4c5f..50278c300d103 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts @@ -5,4 +5,4 @@ */ export { FieldVisConfig } from './field_vis_config'; -export { FieldRequestConfig } from './request'; +export { FieldHistogramRequestConfig, FieldRequestConfig } from './request'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts index 9a886cbc899c2..fd4888b8729c1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KBN_FIELD_TYPES } from '../../../../../../../../src/plugins/data/public'; + import { ML_JOB_FIELD_TYPES } from '../../../../../common/constants/field_types'; export interface FieldRequestConfig { @@ -11,3 +13,8 @@ export interface FieldRequestConfig { type: ML_JOB_FIELD_TYPES; cardinality: number; } + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts index a08821c65bfe7..34f86ffa18788 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts @@ -6,15 +6,17 @@ import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../../util/dependency_cache'; +import { CoreSetup } from 'src/core/public'; + import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; import { SavedSearchQuery } from '../../../contexts/ml'; import { OMIT_FIELDS } from '../../../../../common/constants/field_types'; import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../common/constants/field_histograms'; import { ml } from '../../../services/ml_api_service'; -import { FieldRequestConfig } from '../common'; +import { FieldHistogramRequestConfig, FieldRequestConfig } from '../common'; // Maximum number of examples to obtain for text type fields. const MAX_EXAMPLES_DEFAULT: number = 10; @@ -23,10 +25,15 @@ export class DataLoader { private _indexPattern: IndexPattern; private _indexPatternTitle: IndexPatternTitle = ''; private _maxExamples: number = MAX_EXAMPLES_DEFAULT; + private _toastNotifications: CoreSetup['notifications']['toasts']; - constructor(indexPattern: IndexPattern, kibanaConfig: any) { + constructor( + indexPattern: IndexPattern, + toastNotifications: CoreSetup['notifications']['toasts'] + ) { this._indexPattern = indexPattern; this._indexPatternTitle = indexPattern.title; + this._toastNotifications = toastNotifications; } async loadOverallData( @@ -90,10 +97,24 @@ export class DataLoader { return stats; } + async loadFieldHistograms( + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ): Promise { + const stats = await ml.getVisualizerFieldHistograms({ + indexPatternTitle: this._indexPatternTitle, + query, + fields, + samplerShardSize, + }); + + return stats; + } + displayError(err: any) { - const toastNotifications = getToastNotifications(); if (err.statusCode === 500) { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.dataLoader.internalServerErrorMessage', { defaultMessage: 'Error loading data in index {index}. {message}. ' + @@ -105,7 +126,7 @@ export class DataLoader { }) ); } else { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.page.errorLoadingDataMessage', { defaultMessage: 'Error loading data in index {index}. {message}', values: { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 97b4043c9fd64..3c332d305d7e9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState } from 'react'; import { merge } from 'rxjs'; import { i18n } from '@kbn/i18n'; @@ -43,6 +43,7 @@ import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { getToastNotifications } from '../../util/dependency_cache'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -107,7 +108,10 @@ export const Page: FC = () => { autoRefreshSelector: true, }); - const dataLoader = new DataLoader(currentIndexPattern, kibanaConfig); + const dataLoader = useMemo(() => new DataLoader(currentIndexPattern, getToastNotifications()), [ + currentIndexPattern, + ]); + const [globalState, setGlobalState] = useUrlState('_g'); useEffect(() => { if (globalState?.time !== undefined) { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index d1b6f95f32bed..599e4d4bb8a10 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -27,7 +27,10 @@ import { ModelSnapshot, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; +import { + FieldHistogramRequestConfig, + FieldRequestConfig, +} from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; import { getHttp } from '../../util/dependency_cache'; @@ -494,6 +497,30 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, + getVisualizerFieldHistograms({ + indexPatternTitle, + query, + fields, + samplerShardSize, + }: { + indexPatternTitle: string; + query: any; + fields: FieldHistogramRequestConfig[]; + samplerShardSize?: number; + }) { + const body = JSON.stringify({ + query, + fields, + samplerShardSize, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_histograms/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + getVisualizerOverallStats({ indexPatternTitle, query, diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index d58c797b446db..d1a4a0b585fbb 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyCallAPIOptions, LegacyAPICaller } from 'kibana/server'; +import { LegacyAPICaller } from 'kibana/server'; import _ from 'lodash'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/server'; import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types'; import { getSafeAggregationName } from '../../../common/util/job_utils'; +import { stringHash } from '../../../common/util/string_utils'; import { buildBaseFilterCriteria, buildSamplerAggregation, @@ -19,6 +21,8 @@ const SAMPLER_TOP_TERMS_SHARD_SIZE = 5000; const AGGREGATABLE_EXISTS_REQUEST_BATCH_SIZE = 200; const FIELDS_REQUEST_BATCH_SIZE = 10; +const MAX_CHART_COLUMNS = 20; + interface FieldData { fieldName: string; existsInDocs: boolean; @@ -35,6 +39,11 @@ export interface Field { cardinality: number; } +export interface HistogramField { + fieldName: string; + type: string; +} + interface Distribution { percentiles: any[]; minPercentile: number; @@ -98,6 +107,70 @@ interface FieldExamples { examples: any[]; } +interface NumericColumnStats { + interval: number; + min: number; + max: number; +} +type NumericColumnStatsMap = Record; + +interface AggHistogram { + histogram: { + field: string; + interval: number; + }; +} + +interface AggCardinality { + cardinality: { + field: string; + }; +} + +interface AggTerms { + terms: { + field: string; + size: number; + }; +} + +interface NumericDataItem { + key: number; + key_as_string?: string; + doc_count: number; +} + +interface NumericChartData { + data: NumericDataItem[]; + id: string; + interval: number; + stats: [number, number]; + type: 'numeric'; +} + +interface OrdinalDataItem { + key: string; + key_as_string?: string; + doc_count: number; +} + +interface OrdinalChartData { + type: 'ordinal' | 'boolean'; + cardinality: number; + data: OrdinalDataItem[]; + id: string; +} + +interface UnsupportedChartData { + id: string; + type: 'unsupported'; +} + +type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; + +// type ChartDataItem = NumericDataItem | OrdinalDataItem; +type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData; + type BatchStats = | NumericFieldStats | StringFieldStats @@ -106,12 +179,176 @@ type BatchStats = | DocumentCountStats | FieldExamples; +const getAggIntervals = async ( + callAsCurrentUser: LegacyAPICaller, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +): Promise => { + const numericColumns = fields.filter((field) => { + return field.type === KBN_FIELD_TYPES.NUMBER || field.type === KBN_FIELD_TYPES.DATE; + }); + + if (numericColumns.length === 0) { + return {}; + } + + const minMaxAggs = numericColumns.reduce((aggs, c) => { + const id = stringHash(c.fieldName); + aggs[id] = { + stats: { + field: c.fieldName, + }, + }; + return aggs; + }, {} as Record); + + const respStats = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(minMaxAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 ? _.get(respStats.aggregations, aggsPath) : respStats.aggregations; + + return Object.keys(aggregations).reduce((p, aggName) => { + const stats = [aggregations[aggName].min, aggregations[aggName].max]; + if (!stats.includes(null)) { + const delta = aggregations[aggName].max - aggregations[aggName].min; + + let aggInterval = 1; + + if (delta > MAX_CHART_COLUMNS || delta <= 1) { + aggInterval = delta / (MAX_CHART_COLUMNS - 1); + } + + p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; + } + + return p; + }, {} as NumericColumnStatsMap); +}; + +// export for re-use by transforms plugin +export const getHistogramsForFields = async ( + callAsCurrentUser: LegacyAPICaller, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) => { + const aggIntervals = await getAggIntervals( + callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + const chartDataAggs = fields.reduce((aggs, field) => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(fieldName); + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] !== undefined) { + aggs[`${id}_histogram`] = { + histogram: { + field: fieldName, + interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, + }, + }; + } + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + if (fieldType === KBN_FIELD_TYPES.STRING) { + aggs[`${id}_cardinality`] = { + cardinality: { + field: fieldName, + }, + }; + } + aggs[`${id}_terms`] = { + terms: { + field: fieldName, + size: MAX_CHART_COLUMNS, + }, + }; + } + return aggs; + }, {} as Record); + + if (Object.keys(chartDataAggs).length === 0) { + return []; + } + + const respChartsData = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(chartDataAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 + ? _.get(respChartsData.aggregations, aggsPath) + : respChartsData.aggregations; + + const chartsData: ChartData[] = fields.map( + (field): ChartData => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(field.fieldName); + + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] === undefined) { + return { + type: 'numeric', + data: [], + interval: 0, + stats: [0, 0], + id: fieldName, + }; + } + + return { + data: aggregations[`${id}_histogram`].buckets, + interval: aggIntervals[id].interval, + stats: [aggIntervals[id].min, aggIntervals[id].max], + type: 'numeric', + id: fieldName, + }; + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + return { + type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', + cardinality: + fieldType === KBN_FIELD_TYPES.STRING ? aggregations[`${id}_cardinality`].value : 2, + data: aggregations[`${id}_terms`].buckets, + id: fieldName, + }; + } + + return { + type: 'unsupported', + id: fieldName, + }; + } + ); + + return chartsData; +}; + export class DataVisualizer { - callAsCurrentUser: ( - endpoint: string, - clientParams: Record, - options?: LegacyCallAPIOptions - ) => Promise; + callAsCurrentUser: LegacyAPICaller; constructor(callAsCurrentUser: LegacyAPICaller) { this.callAsCurrentUser = callAsCurrentUser; @@ -200,6 +437,24 @@ export class DataVisualizer { return stats; } + // Obtains binned histograms for supplied list of fields. The statistics for each field in the + // returned array depend on the type of the field (keyword, number, date etc). + // Sampling will be used if supplied samplerShardSize > 0. + async getHistogramsForFields( + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number + ): Promise { + return await getHistogramsForFields( + this.callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + } + // Obtains statistics for supplied list of fields. The statistics for each field in the // returned array depend on the type of the field (keyword, number, date etc). // Sampling will be used if supplied samplerShardSize > 0. diff --git a/x-pack/plugins/ml/server/models/data_visualizer/index.ts b/x-pack/plugins/ml/server/models/data_visualizer/index.ts index ed44e9b12e1d1..ca1df0fe8300c 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/index.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { DataVisualizer } from './data_visualizer'; +export { getHistogramsForFields, DataVisualizer } from './data_visualizer'; diff --git a/x-pack/plugins/ml/server/routes/data_visualizer.ts b/x-pack/plugins/ml/server/routes/data_visualizer.ts index 04008a896a1a2..9dd010e105b6e 100644 --- a/x-pack/plugins/ml/server/routes/data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/data_visualizer.ts @@ -7,8 +7,9 @@ import { RequestHandlerContext } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { DataVisualizer } from '../models/data_visualizer'; -import { Field } from '../models/data_visualizer/data_visualizer'; +import { Field, HistogramField } from '../models/data_visualizer/data_visualizer'; import { + dataVisualizerFieldHistogramsSchema, dataVisualizerFieldStatsSchema, dataVisualizerOverallStatsSchema, indexPatternTitleSchema, @@ -65,10 +66,68 @@ function getStatsForFields( ); } +function getHistogramsForFields( + context: RequestHandlerContext, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) { + const dv = new DataVisualizer(context.ml!.mlClient.callAsCurrentUser); + return dv.getHistogramsForFields(indexPatternTitle, query, fields, samplerShardSize); +} + /** * Routes for the index data visualizer. */ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) { + /** + * @apiGroup DataVisualizer + * + * @api {post} /api/ml/data_visualizer/get_field_stats/:indexPatternTitle Get histograms for fields + * @apiName GetHistogramsForFields + * @apiDescription Returns the histograms on a list fields in the specified index pattern. + * + * @apiSchema (params) indexPatternTitleSchema + * @apiSchema (body) dataVisualizerFieldHistogramsSchema + * + * @apiSuccess {Object} fieldName histograms by field, keyed on the name of the field. + */ + router.post( + { + path: '/api/ml/data_visualizer/get_field_histograms/{indexPatternTitle}', + validate: { + params: indexPatternTitleSchema, + body: dataVisualizerFieldHistogramsSchema, + }, + options: { + tags: ['access:ml:canAccessML'], + }, + }, + mlLicense.basicLicenseAPIGuard(async (context, request, response) => { + try { + const { + params: { indexPatternTitle }, + body: { query, fields, samplerShardSize }, + } = request; + + const results = await getHistogramsForFields( + context, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + return response.ok({ + body: results, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup DataVisualizer * diff --git a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts index b2d665954bd4d..24e45514e1efc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts @@ -11,6 +11,15 @@ export const indexPatternTitleSchema = schema.object({ indexPatternTitle: schema.string(), }); +export const dataVisualizerFieldHistogramsSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + export const dataVisualizerFieldStatsSchema = schema.object({ /** Query to match documents in the index. */ query: schema.any(), diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts index 3fca8ea1ba047..100433b23f7d1 100644 --- a/x-pack/plugins/ml/server/shared.ts +++ b/x-pack/plugins/ml/server/shared.ts @@ -8,3 +8,4 @@ export * from '../common/types/anomalies'; export * from '../common/types/anomaly_detection_jobs'; export * from './lib/capabilities/errors'; export { ModuleSetupPayload } from './shared_services/providers/modules'; +export { getHistogramsForFields } from './models/data_visualizer/'; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 56528370a3ab9..1d2752b9e939d 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -5,6 +5,9 @@ */ import { useMemo } from 'react'; + +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; + import { TransformId, TransformEndpointRequest, @@ -17,6 +20,15 @@ import { useAppDependencies } from '../app_dependencies'; import { GetTransformsResponse, PreviewRequestBody } from '../common'; import { EsIndex } from './use_api_types'; +import { SavedSearchQuery } from './use_search_items'; + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} export const useApi = () => { const { http } = useAppDependencies(); @@ -85,6 +97,20 @@ export const useApi = () => { getIndices(): Promise { return http.get(`/api/index_management/indices`); }, + getHistogramsForFields( + indexPatternTitle: string, + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ) { + return http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + body: JSON.stringify({ + query, + fields, + samplerShardSize, + }), + }); + }, }), [http] ); diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index c821c183ad370..ad5850f26be2e 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -9,7 +9,7 @@ import { useEffect } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { - fetchChartsData, + getFieldType, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, getErrorMessage, @@ -107,13 +107,16 @@ export const useIndexData = ( const fetchColumnChartsData = async function () { try { - const columnChartsData = await fetchChartsData( + const columnChartsData = await api.getHistogramsForFields( indexPattern.title, - api.esSearch, - isDefaultQuery(query) ? matchAllQuery : query, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + isDefaultQuery(query) ? matchAllQuery : query ); - setColumnCharts(columnChartsData); } catch (e) { showDataGridColumnChartErrorMessageToast(e, toastNotifications); diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index e0bbcd0b5d9db..abbc39dd6c728 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -14,7 +14,7 @@ export { } from '../../../../src/plugins/es_ui_shared/public'; export { - fetchChartsData, + getFieldType, getErrorMessage, extractErrorMessage, formatHumanReadableDateTimeSeconds, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts new file mode 100644 index 0000000000000..d602e49338846 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ +/* + * 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 { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { getHistogramsForFields } from '../../shared_imports'; +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { wrapError } from './error_utils'; +import { fieldHistogramsSchema, indexPatternTitleSchema, IndexPatternTitleSchema } from './schema'; + +export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { + router.post( + { + path: addBasePath('field_histograms/{indexPatternTitle}'), + validate: { + params: indexPatternTitleSchema, + body: fieldHistogramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { indexPatternTitle } = req.params as IndexPatternTitleSchema; + const { query, fields, samplerShardSize } = req.body; + + try { + const resp = await getHistogramsForFields( + ctx.transform!.dataClient.callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + return res.ok({ body: resp }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts index 7da3f1ccfe55e..8aadef81b221b 100644 --- a/x-pack/plugins/transform/server/routes/api/schema.ts +++ b/x-pack/plugins/transform/server/routes/api/schema.ts @@ -5,6 +5,24 @@ */ import { schema } from '@kbn/config-schema'; +export const fieldHistogramsSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + +export const indexPatternTitleSchema = schema.object({ + /** Title of the index pattern for which to return stats. */ + indexPatternTitle: schema.string(), +}); + +export interface IndexPatternTitleSchema { + indexPatternTitle: string; +} + export const schemaTransformId = { params: schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts index 07c21e58e64e4..4f35b094017a4 100644 --- a/x-pack/plugins/transform/server/routes/index.ts +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -6,6 +6,7 @@ import { RouteDependencies } from '../types'; +import { registerFieldHistogramsRoutes } from './api/field_histograms'; import { registerPrivilegesRoute } from './api/privileges'; import { registerTransformsRoutes } from './api/transforms'; @@ -15,6 +16,7 @@ export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; export class ApiRoutes { setup(dependencies: RouteDependencies) { + registerFieldHistogramsRoutes(dependencies); registerPrivilegesRoute(dependencies); registerTransformsRoutes(dependencies); } diff --git a/x-pack/plugins/transform/server/shared_imports.ts b/x-pack/plugins/transform/server/shared_imports.ts new file mode 100644 index 0000000000000..d1f86ac375721 --- /dev/null +++ b/x-pack/plugins/transform/server/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { getHistogramsForFields } from '../../ml/server'; diff --git a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts new file mode 100644 index 0000000000000..8b21c367d29f6 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts @@ -0,0 +1,122 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const fieldHistogramsTestData = { + testTitle: 'returns histogram data for fields', + index: 'ft_farequote', + user: USER.ML_POWERUSER, + requestBody: { + query: { bool: { should: [{ match_phrase: { airline: 'JZA' } }], minimum_should_match: 1 } }, + fields: [ + { fieldName: '@timestamp', type: 'date' }, + { fieldName: 'airline', type: 'string' }, + { fieldName: 'responsetime', type: 'number' }, + ], + samplerShardSize: -1, // No sampling, as otherwise counts could vary on each run. + }, + expected: { + responseCode: 200, + responseBody: [ + { + dataLength: 20, + type: 'numeric', + id: '@timestamp', + }, + { type: 'ordinal', dataLength: 1, id: 'airline' }, + { + dataLength: 20, + type: 'numeric', + id: 'responsetime', + }, + ], + }, + }; + + const errorTestData = { + testTitle: 'returns error for index which does not exist', + index: 'ft_farequote_not_exists', + user: USER.ML_POWERUSER, + requestBody: { + query: { bool: { must: [{ match_all: {} }] } }, + fields: [{ fieldName: 'responsetime', type: 'number' }], + samplerShardSize: -1, + }, + expected: { + responseCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: + '[index_not_found_exception] no such index [ft_farequote_not_exists], with { resource.type="index_or_alias" & resource.id="ft_farequote_not_exists" & index_uuid="_na_" & index="ft_farequote_not_exists" }', + }, + }, + }; + + async function runGetFieldHistogramsRequest( + index: string, + user: USER, + requestBody: object, + expectedResponsecode: number + ): Promise { + const { body } = await supertest + .post(`/api/ml/data_visualizer/get_field_histograms/${index}`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(expectedResponsecode); + + return body; + } + + describe('get_field_histograms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + it(`${fieldHistogramsTestData.testTitle}`, async () => { + const body = await runGetFieldHistogramsRequest( + fieldHistogramsTestData.index, + fieldHistogramsTestData.user, + fieldHistogramsTestData.requestBody, + fieldHistogramsTestData.expected.responseCode + ); + + const expected = fieldHistogramsTestData.expected; + + const actual = body.map((b: any) => ({ + dataLength: b.data.length, + type: b.type, + id: b.id, + })); + expect(actual).to.eql(expected.responseBody); + }); + + it(`${errorTestData.testTitle}`, async () => { + const body = await runGetFieldHistogramsRequest( + errorTestData.index, + errorTestData.user, + errorTestData.requestBody, + errorTestData.expected.responseCode + ); + + expect(body.error).to.eql(errorTestData.expected.responseBody.error); + expect(body.message).to.eql(errorTestData.expected.responseBody.message); + }); + }); +}; diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 6cdb9caa1e2db..4ae93296f9be0 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -37,6 +37,18 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '5mb', createIndexPattern: true, expected: { + histogramCharts: [ + { chartAvailable: true, id: '1stFlrSF', legend: '334 - 4692' }, + { chartAvailable: true, id: 'BsmtFinSF1', legend: '0 - 5644' }, + { chartAvailable: true, id: 'BsmtQual', legend: '0 - 5' }, + { chartAvailable: true, id: 'CentralAir', legend: '2 categories' }, + { chartAvailable: true, id: 'Condition2', legend: '2 categories' }, + { chartAvailable: true, id: 'Electrical', legend: '2 categories' }, + { chartAvailable: true, id: 'ExterQual', legend: '1 - 4' }, + { chartAvailable: true, id: 'Exterior1st', legend: '2 categories' }, + { chartAvailable: true, id: 'Exterior2nd', legend: '3 categories' }, + { chartAvailable: true, id: 'Fireplaces', legend: '0 - 3' }, + ], row: { type: 'outlier_detection', status: 'stopped', @@ -84,6 +96,16 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); }); + it('enables the source data preview histogram charts', async () => { + await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(); + }); + + it('displays the source data preview histogram charts', async () => { + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewHistogramCharts( + testData.expected.histogramCharts + ); + }); + it('displays the include fields selection', async () => { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); }); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 1b756bbaca5d8..fc4aaa4fbf5fd 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -128,6 +128,58 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await testSubjects.existOrFail('mlAnalyticsCreationDataGrid loaded', { timeout: 5000 }); }, + async assertIndexPreviewHistogramChartButtonExists() { + await testSubjects.existOrFail('mlAnalyticsCreationDataGridHistogramButton'); + }, + + async enableSourceDataPreviewHistogramCharts() { + await this.assertSourceDataPreviewHistogramChartButtonCheckState(false); + await testSubjects.click('mlAnalyticsCreationDataGridHistogramButton'); + await this.assertSourceDataPreviewHistogramChartButtonCheckState(true); + }, + + async assertSourceDataPreviewHistogramChartButtonCheckState(expectedCheckState: boolean) { + const actualCheckState = + (await testSubjects.getAttribute( + 'mlAnalyticsCreationDataGridHistogramButton', + 'aria-checked' + )) === 'true'; + expect(actualCheckState).to.eql( + expectedCheckState, + `Chart histogram button check state should be '${expectedCheckState}' (got '${actualCheckState}')` + ); + }, + + async assertSourceDataPreviewHistogramCharts( + expectedHistogramCharts: Array<{ chartAvailable: boolean; id: string; legend: string }> + ) { + // For each chart, get the content of each header cell and assert + // the legend text and column id and if the chart should be present or not. + await retry.tryForTime(5000, async () => { + for (const [index, expected] of expectedHistogramCharts.entries()) { + await testSubjects.existOrFail(`mlDataGridChart-${index}`); + + if (expected.chartAvailable) { + await testSubjects.existOrFail(`mlDataGridChart-${index}-histogram`); + } else { + await testSubjects.missingOrFail(`mlDataGridChart-${index}-histogram`); + } + + const actualLegend = await testSubjects.getVisibleText(`mlDataGridChart-${index}-legend`); + expect(actualLegend).to.eql( + expected.legend, + `Legend text for column '${index}' should be '${expected.legend}' (got '${actualLegend}')` + ); + + const actualId = await testSubjects.getVisibleText(`mlDataGridChart-${index}-id`); + expect(actualId).to.eql( + expected.id, + `Id text for column '${index}' should be '${expected.id}' (got '${actualId}')` + ); + } + }); + }, + async assertIncludeFieldsSelectionExists() { await testSubjects.existOrFail('mlAnalyticsCreateJobWizardIncludesSelect', { timeout: 5000 }); }, From fdc999769d9d9ab1b1e8856d71ca93a0ccc052fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 14 Jul 2020 13:47:03 +0200 Subject: [PATCH 13/58] [Index template wizard] Remove shadow and use border for components panels (#71606) --- .../component_template_selector/component_templates.scss | 4 +++- .../component_templates_selector.scss | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss index 51e8a829e81b1..026e63b2b4caa 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss @@ -7,7 +7,8 @@ $heightHeader: $euiSizeL * 2; .componentTemplates { - @include euiBottomShadowFlat; + border: $euiBorderThin; + border-top: none; height: 100%; &__header { @@ -20,6 +21,7 @@ $heightHeader: $euiSizeL * 2; &__searchBox { border-bottom: $euiBorderThin; + border-top: $euiBorderThin; box-shadow: none; max-width: initial; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss index 61d5512da2cd9..041fc1c8bf9a4 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss @@ -6,7 +6,7 @@ height: 480px; &__selection { - @include euiBottomShadowFlat; + border: $euiBorderThin; padding: 0 $euiSize $euiSize; color: $euiColorDarkShade; From 97afee5b06dec9a8db28ec2309bd684199c21aad Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 14 Jul 2020 08:12:51 -0400 Subject: [PATCH 14/58] [Security Solution] Hide timeline footer when Resolver is open (#71516) * Hide the Timeline footer, in the event viewer, if Resolver is showing --- .../events_viewer/events_viewer.tsx | 44 ++++++++++------- .../common/components/events_viewer/index.tsx | 10 +++- .../components/timeline/body/helpers.ts | 3 -- .../components/timeline/body/index.test.tsx | 30 +++++++++++- .../components/timeline/body/index.tsx | 5 +- .../components/timeline/header/index.tsx | 3 +- .../components/timeline/timeline.test.tsx | 28 +++++++++++ .../components/timeline/timeline.tsx | 48 +++++++++++-------- 8 files changed, 123 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 0a1f95d51e300..a81c5facb0718 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -67,6 +67,8 @@ interface Props { sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; + // If truthy, the graph viewer (Resolver) is showing + graphEventId: string | undefined; } const EventsViewerComponent: React.FC = ({ @@ -90,6 +92,7 @@ const EventsViewerComponent: React.FC = ({ sort, toggleColumn, utilityBar, + graphEventId, }) => { const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); @@ -191,22 +194,28 @@ const EventsViewerComponent: React.FC = ({ toggleColumn={toggleColumn} /> -
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index 23da9669ee9a5..6e0863f1a6e5b 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -34,6 +34,7 @@ import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL, } from './explorer_constants'; +import { ANNOTATION_EVENT_USER } from '../../../common/constants/annotations'; // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. @@ -395,6 +396,12 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, earliestMs: timeRange.earliestMs, latestMs: timeRange.latestMs, maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], }) .toPromise() .then((resp) => { @@ -410,16 +417,17 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, } }); - return resolve( - annotationsData + return resolve({ + annotationsData: annotationsData .sort((a, b) => { return a.timestamp - b.timestamp; }) .map((d, i) => { d.key = String.fromCharCode(65 + i); return d; - }) - ); + }), + aggregations: resp.aggregations, + }); }) .catch((resp) => { console.log('Error loading list of annotations for jobs list:', resp); diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index c55c06c80ab81..a38044a8b3425 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -113,7 +113,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo const { annotationsData, overallState, tableData } = payload; nextState = { ...state, - annotationsData, + annotations: annotationsData, overallSwimlaneData: overallState, tableData, viewBySwimlaneData: { diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 892b46467345b..889d572f4fabc 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -21,10 +21,14 @@ import { SwimlaneData, ViewBySwimLaneData, } from '../../explorer_utils'; +import { Annotations, EsAggregationResult } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; export interface ExplorerState { - annotationsData: any[]; + annotations: { + annotationsData: Annotations; + aggregations: EsAggregationResult; + }; bounds: TimeRangeBounds | undefined; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; @@ -62,7 +66,10 @@ function getDefaultIndexPattern() { export function getExplorerDefaultState(): ExplorerState { return { - annotationsData: [], + annotations: { + annotationsData: [], + aggregations: {}, + }, bounds: undefined, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 5c22a440a103e..7d09797a0ff1b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -157,7 +157,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim }, [explorerAppState]); const explorerState = useObservable(explorerService.state$); - const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts index 29a5732026761..f9e19ba6f757e 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Annotation } from '../../../../common/types/annotations'; +import { + Annotation, + FieldToBucket, + GetAnnotationsResponse, +} from '../../../../common/types/annotations'; import { http, http$ } from '../http_service'; import { basePath } from './index'; @@ -14,15 +18,19 @@ export const annotations = { earliestMs: number; latestMs: number; maxAnnotations: number; + fields: FieldToBucket[]; + detectorIndex: number; + entities: any[]; }) { const body = JSON.stringify(obj); - return http$<{ annotations: Record }>({ + return http$({ path: `${basePath()}/annotations`, method: 'POST', body, }); }, - indexAnnotation(obj: any) { + + indexAnnotation(obj: Annotation) { const body = JSON.stringify(obj); return http({ path: `${basePath()}/annotations/index`, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index d4470e7502e0d..95dc1ed6988f6 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -28,6 +28,8 @@ import { EuiSelect, EuiSpacer, EuiTitle, + EuiAccordion, + EuiBadge, } from '@elastic/eui'; import { getToastNotifications } from '../util/dependency_cache'; @@ -125,6 +127,8 @@ function getTimeseriesexplorerDefaultState() { entitiesLoading: false, entityValues: {}, focusAnnotationData: [], + focusAggregations: {}, + focusAggregationInterval: {}, focusChartData: undefined, focusForecastData: undefined, fullRefresh: true, @@ -1025,6 +1029,7 @@ export class TimeSeriesExplorer extends React.Component { entityValues, focusAggregationInterval, focusAnnotationData, + focusAggregations, focusChartData, focusForecastData, fullRefresh, @@ -1075,8 +1080,8 @@ export class TimeSeriesExplorer extends React.Component { const entityControls = this.getControlsForDetector(); const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues(); const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided(); - - const detectorSelectOptions = getViewableDetectors(selectedJob).map((d) => ({ + const detectors = getViewableDetectors(selectedJob); + const detectorSelectOptions = detectors.map((d) => ({ value: d.index, text: d.detector_description, })); @@ -1311,25 +1316,49 @@ export class TimeSeriesExplorer extends React.Component { )} - {showAnnotations && focusAnnotationData.length > 0 && ( -
- -

- -

-
+ {focusAnnotationData && focusAnnotationData.length > 0 && ( + +

+ + + + ), + }} + /> +

+ + } + > -
+ )} - +

number; @@ -37,6 +38,7 @@ export interface FocusData { showForecastCheckbox?: any; focusAnnotationData?: any; focusForecastData?: any; + focusAggregations?: any; } export function getFocusData( @@ -84,11 +86,23 @@ export function getFocusData( earliestMs: searchBounds.min.valueOf(), latestMs: searchBounds.max.valueOf(), maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], + detectorIndex, + entities: nonBlankEntities, }) .pipe( catchError(() => { // silent fail - return of({ annotations: {} as Record }); + return of({ + annotations: {} as Record, + aggregations: {}, + success: false, + }); }) ), // Plus query for forecast data if there is a forecastId stored in the appState. @@ -146,13 +160,14 @@ export function getFocusData( d.key = String.fromCharCode(65 + i); return d; }); + + refreshFocusData.focusAggregations = annotations.aggregations; } if (forecastData) { refreshFocusData.focusForecastData = processForecastResults(forecastData.results); refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0; } - return refreshFocusData; }) ); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index c2582107062bb..f7353034b7453 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -8,7 +8,8 @@ import Boom from 'boom'; import _ from 'lodash'; import { ILegacyScopedClusterClient } from 'kibana/server'; -import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; +import { ANNOTATION_EVENT_USER, ANNOTATION_TYPE } from '../../../common/constants/annotations'; +import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; import { ML_ANNOTATIONS_INDEX_ALIAS_READ, ML_ANNOTATIONS_INDEX_ALIAS_WRITE, @@ -19,20 +20,35 @@ import { Annotations, isAnnotation, isAnnotations, + getAnnotationFieldName, + getAnnotationFieldValue, + EsAggregationResult, } from '../../../common/types/annotations'; // TODO All of the following interface/type definitions should // eventually be replaced by the proper upstream definitions interface EsResult { - _source: object; + _source: Annotation; _id: string; } +export interface FieldToBucket { + field: string; + missing?: string | number; +} + export interface IndexAnnotationArgs { jobIds: string[]; earliestMs: number; latestMs: number; maxAnnotations: number; + fields?: FieldToBucket[]; + detectorIndex?: number; + entities?: any[]; +} + +export interface AggTerm { + terms: FieldToBucket; } export interface GetParams { @@ -43,9 +59,8 @@ export interface GetParams { export interface GetResponse { success: true; - annotations: { - [key: string]: Annotations; - }; + annotations: Record; + aggregations: EsAggregationResult; } export interface IndexParams { @@ -96,10 +111,14 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl earliestMs, latestMs, maxAnnotations, + fields, + detectorIndex, + entities, }: IndexAnnotationArgs) { const obj: GetResponse = { success: true, annotations: {}, + aggregations: {}, }; const boolCriteria: object[] = []; @@ -182,6 +201,64 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl }); } + // Find unique buckets (e.g. events) from the queried annotations to show in dropdowns + const aggs: Record = {}; + if (fields) { + fields.forEach((fieldToBucket) => { + aggs[fieldToBucket.field] = { + terms: { + ...fieldToBucket, + }, + }; + }); + } + + // Build should clause to further query for annotations in SMV + // we want to show either the exact match with detector index and by/over/partition fields + // OR annotations without any partition fields defined + let shouldClauses; + if (detectorIndex !== undefined && Array.isArray(entities)) { + // build clause to get exact match of detector index and by/over/partition fields + const beExactMatch = []; + beExactMatch.push({ + term: { + detector_index: detectorIndex, + }, + }); + + entities.forEach(({ fieldName, fieldType, fieldValue }) => { + beExactMatch.push({ + term: { + [getAnnotationFieldName(fieldType)]: fieldName, + }, + }); + beExactMatch.push({ + term: { + [getAnnotationFieldValue(fieldType)]: fieldValue, + }, + }); + }); + + // clause to get annotations that have no partition fields + const haveAnyPartitionFields: object[] = []; + PARTITION_FIELDS.forEach((field) => { + haveAnyPartitionFields.push({ + exists: { + field: getAnnotationFieldName(field), + }, + }); + haveAnyPartitionFields.push({ + exists: { + field: getAnnotationFieldValue(field), + }, + }); + }); + shouldClauses = [ + { bool: { must_not: haveAnyPartitionFields } }, + { bool: { must: beExactMatch } }, + ]; + } + const params: GetParams = { index: ML_ANNOTATIONS_INDEX_ALIAS_READ, size: maxAnnotations, @@ -201,8 +278,10 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl }, }, ], + ...(shouldClauses ? { should: shouldClauses, minimum_should_match: 1 } : {}), }, }, + ...(fields ? { aggs } : {}), }, }; @@ -217,9 +296,19 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl const docs: Annotations = _.get(resp, ['hits', 'hits'], []).map((d: EsResult) => { // get the original source document and the document id, we need it // to identify the annotation when editing/deleting it. - return { ...d._source, _id: d._id } as Annotation; + // if original `event` is undefined then substitute with 'user` by default + // since annotation was probably generated by user on the UI + return { + ...d._source, + event: d._source?.event ?? ANNOTATION_EVENT_USER, + _id: d._id, + } as Annotation; }); + const aggregations = _.get(resp, ['aggregations'], {}) as EsAggregationResult; + if (fields) { + obj.aggregations = aggregations; + } if (isAnnotations(docs) === false) { // No need to translate, this will not be exposed in the UI. throw new Error(`Annotations didn't pass integrity check.`); diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index d7403c45f1be2..663ee846571e7 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -6,13 +6,11 @@ import Boom from 'boom'; import { ILegacyScopedClusterClient } from 'kibana/server'; +import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; +import { PartitionFieldsType } from '../../../common/types/anomalies'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { CriteriaField } from './results_service'; -const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; - -type PartitionFieldsType = typeof PARTITION_FIELDS[number]; - type SearchTerm = | { [key in PartitionFieldsType]?: string; diff --git a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts index fade2093ac842..14a2f632419bc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts @@ -16,6 +16,14 @@ export const indexAnnotationSchema = schema.object({ create_username: schema.maybe(schema.string()), modified_time: schema.maybe(schema.number()), modified_username: schema.maybe(schema.string()), + event: schema.maybe(schema.string()), + detector_index: schema.maybe(schema.number()), + partition_field_name: schema.maybe(schema.string()), + partition_field_value: schema.maybe(schema.string()), + over_field_name: schema.maybe(schema.string()), + over_field_value: schema.maybe(schema.string()), + by_field_name: schema.maybe(schema.string()), + by_field_value: schema.maybe(schema.string()), /** Document id */ _id: schema.maybe(schema.string()), key: schema.maybe(schema.string()), @@ -26,6 +34,25 @@ export const getAnnotationsSchema = schema.object({ earliestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), latestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), maxAnnotations: schema.number(), + /** Fields to find unique values for (e.g. events or created_by) */ + fields: schema.maybe( + schema.arrayOf( + schema.object({ + field: schema.string(), + missing: schema.maybe(schema.string()), + }) + ) + ), + detectorIndex: schema.maybe(schema.number()), + entities: schema.maybe( + schema.arrayOf( + schema.object({ + fieldType: schema.maybe(schema.string()), + fieldName: schema.maybe(schema.string()), + fieldValue: schema.maybe(schema.string()), + }) + ) + ), }); export const deleteAnnotationSchema = schema.object({ annotationId: schema.string() }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c8fe792af926d..287cf443b1b07 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9689,7 +9689,6 @@ "xpack.ml.datavisualizerBreadcrumbLabel": "データビジュアライザー", "xpack.ml.dataVisualizerPageLabel": "データビジュアライザー", "xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "メッセージを読み込めませんでした", - "xpack.ml.explorer.annotationsTitle": "注釈", "xpack.ml.explorer.anomaliesTitle": "異常", "xpack.ml.explorer.anomalyTimelineTitle": "異常のタイムライン", "xpack.ml.explorer.charts.detectorLabel": "「{fieldName}」で分割された {detectorLabel}{br} Y 軸イベントの分布", @@ -10802,7 +10801,6 @@ "xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "注釈テキストを入力してください", "xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新", "xpack.ml.timeSeriesExplorer.annotationsLabel": "注釈", - "xpack.ml.timeSeriesExplorer.annotationsTitle": "注釈", "xpack.ml.timeSeriesExplorer.anomaliesTitle": "異常", "xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": "、初めのジョブを自動選択します", "xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "リクエストされた‘{invalidIdsCount, plural, one {ジョブ} other {件のジョブ}} {invalidIds} をこのダッシュボードで表示できません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7640675a427ce..ea3aa71b154aa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9694,7 +9694,6 @@ "xpack.ml.datavisualizerBreadcrumbLabel": "数据可视化工具", "xpack.ml.dataVisualizerPageLabel": "数据可视化工具", "xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "无法加载消息", - "xpack.ml.explorer.annotationsTitle": "注释", "xpack.ml.explorer.anomaliesTitle": "异常", "xpack.ml.explorer.anomalyTimelineTitle": "异常时间线", "xpack.ml.explorer.charts.detectorLabel": "{detectorLabel}{br}y 轴事件分布按 “{fieldName}” 分割", @@ -10807,7 +10806,6 @@ "xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "输入注释文本", "xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新", "xpack.ml.timeSeriesExplorer.annotationsLabel": "注释", - "xpack.ml.timeSeriesExplorer.annotationsTitle": "注释", "xpack.ml.timeSeriesExplorer.anomaliesTitle": "异常", "xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": ",自动选择第一个作业", "xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "您无法在此仪表板中查看请求的 {invalidIdsCount, plural, one {作业} other {作业}} {invalidIds}", From 8f8736cce87945d6cac68fb714c1f21fc81ebcf2 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 14 Jul 2020 12:45:15 -0500 Subject: [PATCH 51/58] Fix bug where lists "needs configuration" while index is being created (#71653) The behavior here was that you'd be redirected to detections from wherever you were, with no warning/indication. When we knew we needed an index, and that we could create one, needsConfiguration was incorrectly 'true' during the time between realizing this fact and creating the index. That intermediate state is now captured in needsIndexConfiguration, which is true if we either can't create the index or we failed our attempt to do so. --- .../detection_engine/lists/use_lists_config.tsx | 9 ++++++--- .../detection_engine/lists/use_lists_index.tsx | 10 +++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx index ea5e075811d4b..e21cbceeaef27 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx @@ -19,17 +19,20 @@ export interface UseListsConfigReturn { } export const useListsConfig = (): UseListsConfigReturn => { - const { createIndex, indexExists, loading: indexLoading } = useListsIndex(); + const { createIndex, createIndexError, indexExists, loading: indexLoading } = useListsIndex(); const { canManageIndex, canWriteIndex, loading: privilegesLoading } = useListsPrivileges(); const { lists } = useKibana().services; const enabled = lists != null; const loading = indexLoading || privilegesLoading; const needsIndex = indexExists === false; - const needsConfiguration = !enabled || needsIndex || canWriteIndex === false; + const indexCreationFailed = createIndexError != null; + const needsIndexConfiguration = + needsIndex && (canManageIndex === false || (canManageIndex === true && indexCreationFailed)); + const needsConfiguration = !enabled || canWriteIndex === false || needsIndexConfiguration; useEffect(() => { - if (canManageIndex && needsIndex) { + if (needsIndex && canManageIndex) { createIndex(); } }, [canManageIndex, createIndex, needsIndex]); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx index a9497fd4971c1..75f12bd07d3ae 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx @@ -18,6 +18,8 @@ export interface UseListsIndexState { export interface UseListsIndexReturn extends UseListsIndexState { loading: boolean; createIndex: () => void; + createIndexError: unknown; + createIndexResult: { acknowledged: boolean } | undefined; } export const useListsIndex = (): UseListsIndexReturn => { @@ -96,5 +98,11 @@ export const useListsIndex = (): UseListsIndexReturn => { } }, [createListIndexState.error, toasts]); - return { loading, createIndex, ...state }; + return { + loading, + createIndex, + createIndexError: createListIndexState.error, + createIndexResult: createListIndexState.result, + ...state, + }; }; From 981d678e4207a4d850ae2b4b7fba3cb69a499e59 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 14 Jul 2020 19:53:14 +0200 Subject: [PATCH 52/58] [Uptime] Duration Anomaly Alert (#71208) --- .../providers/results_service.ts | 9 +- .../plugins/uptime/common/constants/alerts.ts | 5 + .../uptime/common/constants/rest_api.ts | 2 + .../lib/__tests__/ml.test.ts} | 2 +- x-pack/plugins/uptime/common/lib/index.ts | 2 + x-pack/plugins/uptime/common/lib/ml.ts | 27 ++++ x-pack/plugins/uptime/kibana.json | 2 +- .../ml/__tests__/ml_manage_job.test.tsx | 8 +- .../monitor/ml/confirm_alert_delete.tsx | 38 +++++ .../components/monitor/ml/manage_ml_job.tsx | 62 ++++++-- .../monitor/ml/ml_flyout_container.tsx | 74 +++++----- .../components/monitor/ml/ml_integeration.tsx | 2 +- .../components/monitor/ml/ml_job_link.tsx | 2 +- .../components/monitor/ml/translations.tsx | 14 ++ .../monitor/ml/use_anomaly_alert.ts | 30 ++++ .../monitor_duration_container.tsx | 2 +- .../alerts/alert_expression_popover.tsx | 2 +- .../alerts/anomaly_alert/anomaly_alert.tsx | 86 +++++++++++ .../alerts/anomaly_alert/select_severity.tsx | 135 ++++++++++++++++++ .../alerts/anomaly_alert/translations.ts | 26 ++++ .../lib/alert_types/duration_anomaly.tsx | 37 +++++ .../uptime/public/lib/alert_types/index.ts | 2 + .../public/lib/alert_types/translations.ts | 22 ++- .../plugins/uptime/public/pages/monitor.tsx | 5 + .../uptime/public/state/actions/alerts.ts | 15 ++ .../plugins/uptime/public/state/actions/ui.ts | 2 + .../plugins/uptime/public/state/api/alerts.ts | 27 ++++ .../uptime/public/state/api/ml_anomaly.ts | 27 +--- .../uptime/public/state/effects/alerts.ts | 39 +++++ .../uptime/public/state/effects/index.ts | 2 + .../uptime/public/state/effects/ml_anomaly.ts | 26 +++- .../uptime/public/state/kibana_service.ts | 4 + .../__tests__/__snapshots__/ui.test.ts.snap | 2 + .../state/reducers/__tests__/ui.test.ts | 6 + .../uptime/public/state/reducers/alerts.ts | 29 ++++ .../uptime/public/state/reducers/index.ts | 2 + .../uptime/public/state/reducers/ui.ts | 7 + .../state/selectors/__tests__/index.test.ts | 5 + .../uptime/public/state/selectors/index.ts | 6 + .../lib/adapters/framework/adapter_types.ts | 2 + .../lib/alerts/__tests__/status_check.test.ts | 41 +++--- .../server/lib/alerts/duration_anomaly.ts | 129 +++++++++++++++++ .../plugins/uptime/server/lib/alerts/index.ts | 2 + .../uptime/server/lib/alerts/translations.ts | 90 ++++++++++++ .../plugins/uptime/server/lib/alerts/types.ts | 8 +- x-pack/plugins/uptime/server/uptime_server.ts | 2 +- .../functional/services/uptime/ml_anomaly.ts | 20 +++ .../apps/uptime/anomaly_alert.ts | 131 +++++++++++++++++ .../apps/uptime/index.ts | 1 + 49 files changed, 1109 insertions(+), 112 deletions(-) rename x-pack/plugins/uptime/{public/state/api/__tests__/ml_anomaly.test.ts => common/lib/__tests__/ml.test.ts} (95%) create mode 100644 x-pack/plugins/uptime/common/lib/ml.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts create mode 100644 x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx create mode 100644 x-pack/plugins/uptime/public/state/actions/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/api/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/effects/alerts.ts create mode 100644 x-pack/plugins/uptime/public/state/reducers/alerts.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts diff --git a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts index 366a1f8b8c6f4..6af4eb008567a 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts @@ -25,7 +25,14 @@ export function getResultsServiceProvider({ }: SharedServicesChecks): ResultsServiceProvider { return { resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - const hasMlCapabilities = getHasMlCapabilities(request); + // Uptime is using this service in anomaly alert, kibana alerting doesn't provide request object + // So we are adding a dummy request for now + // TODO: Remove this once kibana alerting provides request object + const hasMlCapabilities = + request.params !== 'DummyKibanaRequest' + ? getHasMlCapabilities(request) + : (_caps: string[]) => Promise.resolve(); + const { getAnomaliesTableData } = resultsServiceProvider(mlClusterClient); return { async getAnomaliesTableData(...args) { diff --git a/x-pack/plugins/uptime/common/constants/alerts.ts b/x-pack/plugins/uptime/common/constants/alerts.ts index a259fc0a3eb81..61a7a02bf8b30 100644 --- a/x-pack/plugins/uptime/common/constants/alerts.ts +++ b/x-pack/plugins/uptime/common/constants/alerts.ts @@ -20,9 +20,14 @@ export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { id: 'xpack.uptime.alerts.actionGroups.tls', name: 'Uptime TLS Alert', }, + DURATION_ANOMALY: { + id: 'xpack.uptime.alerts.actionGroups.durationAnomaly', + name: 'Uptime Duration Anomaly', + }, }; export const CLIENT_ALERT_TYPES = { MONITOR_STATUS: 'xpack.uptime.alerts.monitorStatus', TLS: 'xpack.uptime.alerts.tls', + DURATION_ANOMALY: 'xpack.uptime.alerts.durationAnomaly', }; diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index 169d175f02d3b..f3f06f776260d 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -24,4 +24,6 @@ export enum API_URLS { ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, ML_CAPABILITIES = '/api/ml/ml_capabilities', ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, + ALERT = '/api/alerts/alert/', + ALERTS_FIND = '/api/alerts/_find', } diff --git a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts similarity index 95% rename from x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts rename to x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts index 838e5b8246b4b..122755638db7f 100644 --- a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts +++ b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMLJobId } from '../ml_anomaly'; +import { getMLJobId } from '../ml'; describe('ML Anomaly API', () => { it('it generates a lowercase job id', async () => { diff --git a/x-pack/plugins/uptime/common/lib/index.ts b/x-pack/plugins/uptime/common/lib/index.ts index 2daec0adf87e4..33fe5b80d469b 100644 --- a/x-pack/plugins/uptime/common/lib/index.ts +++ b/x-pack/plugins/uptime/common/lib/index.ts @@ -6,3 +6,5 @@ export * from './combine_filters_and_user_search'; export * from './stringify_kueries'; + +export { getMLJobId } from './ml'; diff --git a/x-pack/plugins/uptime/common/lib/ml.ts b/x-pack/plugins/uptime/common/lib/ml.ts new file mode 100644 index 0000000000000..8be7c472fa5b9 --- /dev/null +++ b/x-pack/plugins/uptime/common/lib/ml.ts @@ -0,0 +1,27 @@ +/* + * 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 { ML_JOB_ID } from '../constants'; + +export const getJobPrefix = (monitorId: string) => { + // ML App doesn't support upper case characters in job name + // Also Spaces and the characters / ? , " < > | * are not allowed + // so we will replace all special chars with _ + + const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); + + // ML Job ID can't be greater than 64 length, so will be substring it, and hope + // At such big length, there is minimum chance of having duplicate monitor id + // Subtracting ML_JOB_ID constant as well + const postfix = '_' + ML_JOB_ID; + + if ((prefix + postfix).length > 64) { + return prefix.substring(0, 64 - postfix.length) + '_'; + } + return prefix + '_'; +}; + +export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index a057e546e4414..f2b028e323ff6 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "uptime"], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["capabilities", "data", "home", "observability"], + "optionalPlugins": ["capabilities", "data", "home", "observability", "ml"], "requiredPlugins": [ "alerts", "embeddable", diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx index 30038b030be56..841c577a4014b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx @@ -11,8 +11,8 @@ import { renderWithRouter, shallowWithRouter } from '../../../../lib'; describe('Manage ML Job', () => { it('shallow renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); const wrapper = shallowWithRouter( @@ -21,8 +21,8 @@ describe('Manage ML Job', () => { }); it('renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); const wrapper = renderWithRouter( diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx new file mode 100644 index 0000000000000..cd5e509e3ad88 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as labels from './translations'; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmAlertDeletion: React.FC = ({ onConfirm, onCancel }) => { + return ( + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index 248ea179ccd2b..5c3674761af84 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -7,7 +7,8 @@ import React, { useContext, useState } from 'react'; import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; +import { CLIENT_ALERT_TYPES } from '../../../../common/constants'; import { canDeleteMLJobSelector, hasMLJobSelector, @@ -18,6 +19,10 @@ import * as labels from './translations'; import { getMLJobLinkHref } from './ml_job_link'; import { useGetUrlParams } from '../../../hooks'; import { useMonitorId } from '../../../hooks'; +import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions'; +import { useAnomalyAlert } from './use_anomaly_alert'; +import { ConfirmAlertDeletion } from './confirm_alert_delete'; +import { deleteAlertAction } from '../../../state/actions/alerts'; interface Props { hasMLJob: boolean; @@ -40,6 +45,15 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const monitorId = useMonitorId(); + const dispatch = useDispatch(); + + const anomalyAlert = useAnomalyAlert(); + + const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false); + + const deleteAnomalyAlert = () => + dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string })); + const button = ( , + onClick: () => { + if (anomalyAlert) { + setIsConfirmAlertDeleteOpen(true); + } else { + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); + } + }, + }, { name: labels.DISABLE_ANOMALY_DETECTION, 'data-test-subj': 'uptimeDeleteMLJobBtn', @@ -82,12 +111,29 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro ]; return ( - setIsPopOverOpen(false)}> - - + <> + setIsPopOverOpen(false)} + > + + + {isConfirmAlertDeleteOpen && ( + { + deleteAnomalyAlert(); + setIsConfirmAlertDeleteOpen(false); + }} + onCancel={() => { + setIsConfirmAlertDeleteOpen(false); + }} + /> + )} + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx index e4bb3d0ac9e17..84634f328621f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx @@ -13,59 +13,61 @@ import { isMLJobCreatingSelector, selectDynamicSettings, } from '../../../state/selectors'; -import { createMLJobAction, getExistingMLJobAction } from '../../../state/actions'; +import { + createMLJobAction, + getExistingMLJobAction, + setAlertFlyoutType, + setAlertFlyoutVisible, +} from '../../../state/actions'; import { MLJobLink } from './ml_job_link'; import * as labels from './translations'; -import { - useKibana, - KibanaReactNotifications, -} from '../../../../../../../src/plugins/kibana_react/public'; import { MLFlyoutView } from './ml_flyout'; -import { ML_JOB_ID } from '../../../../common/constants'; +import { CLIENT_ALERT_TYPES, ML_JOB_ID } from '../../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; import { useGetUrlParams } from '../../../hooks'; import { getDynamicSettings } from '../../../state/actions/dynamic_settings'; import { useMonitorId } from '../../../hooks'; +import { kibanaService } from '../../../state/kibana_service'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { onClose: () => void; } const showMLJobNotification = ( - notifications: KibanaReactNotifications, monitorId: string, basePath: string, range: { to: string; from: string }, success: boolean, - message = '' + error?: Error ) => { if (success) { - notifications.toasts.success({ - title: ( -

{labels.JOB_CREATED_SUCCESS_TITLE}

- ), - body: ( -

- {labels.JOB_CREATED_SUCCESS_MESSAGE} - - {labels.VIEW_JOB} - -

- ), - toastLifeTimeMs: 10000, - }); + kibanaService.toasts.addSuccess( + { + title: toMountPoint( +

{labels.JOB_CREATED_SUCCESS_TITLE}

+ ), + text: toMountPoint( +

+ {labels.JOB_CREATED_SUCCESS_MESSAGE} + + {labels.VIEW_JOB} + +

+ ), + }, + { toastLifeTimeMs: 10000 } + ); } else { - notifications.toasts.danger({ - title:

{labels.JOB_CREATION_FAILED}

, - body: message ??

{labels.JOB_CREATION_FAILED_MESSAGE}

, + kibanaService.toasts.addError(error!, { + title: labels.JOB_CREATION_FAILED, + toastMessage: labels.JOB_CREATION_FAILED_MESSAGE, toastLifeTimeMs: 10000, }); } }; export const MachineLearningFlyout: React.FC = ({ onClose }) => { - const { notifications } = useKibana(); - const dispatch = useDispatch(); const { data: hasMLJob, error } = useSelector(hasNewMLJobSelector); const isMLJobCreating = useSelector(isMLJobCreatingSelector); @@ -100,7 +102,6 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { if (isCreatingJob && !isMLJobCreating) { if (hasMLJob) { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, @@ -112,31 +113,22 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { loadMLJob(ML_JOB_ID); refreshApp(); + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); } else { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, false, - error?.message || error?.body?.message + error as Error ); } setIsCreatingJob(false); onClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - hasMLJob, - notifications, - onClose, - isCreatingJob, - error, - isMLJobCreating, - monitorId, - dispatch, - basePath, - ]); + }, [hasMLJob, onClose, isCreatingJob, error, isMLJobCreating, monitorId, dispatch, basePath]); useEffect(() => { if (hasExistingMLJob && !isMLJobCreating && !hasMLJob && heartbeatIndices) { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx index 1de19dda3b88f..aa67c7ba1c2f9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx @@ -16,12 +16,12 @@ import { import { deleteMLJobAction, getExistingMLJobAction, resetMLState } from '../../../state/actions'; import { ConfirmJobDeletion } from './confirm_delete'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import * as labels from './translations'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ManageMLJobComponent } from './manage_ml_job'; import { JobStat } from '../../../../../../plugins/ml/public'; import { useMonitorId } from '../../../hooks'; +import { getMLJobId } from '../../../../common/lib'; export const MLIntegrationComponent = () => { const [isMlFlyoutOpen, setIsMlFlyoutOpen] = useState(false); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx index 4b6f7e3ba061d..adc05695b4379 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx @@ -8,7 +8,7 @@ import React from 'react'; import url from 'url'; import { EuiButtonEmpty } from '@elastic/eui'; import rison, { RisonValue } from 'rison-node'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { getMLJobId } from '../../../../common/lib'; interface Props { monitorId: string; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx index bcc3fca770652..90ebdf10a73f5 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx @@ -89,6 +89,20 @@ export const DISABLE_ANOMALY_DETECTION = i18n.translate( } ); +export const ENABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.enableAnomalyAlert', + { + defaultMessage: 'Enable anomaly alert', + } +); + +export const DISABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyAlert', + { + defaultMessage: 'Disable anomaly alert', + } +); + export const MANAGE_ANOMALY_DETECTION = i18n.translate( 'xpack.uptime.ml.enableAnomalyDetectionPanel.manageAnomalyDetectionTitle', { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts new file mode 100644 index 0000000000000..d204cdf10012a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts @@ -0,0 +1,30 @@ +/* + * 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 { useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getExistingAlertAction } from '../../../state/actions/alerts'; +import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors'; +import { UptimeRefreshContext } from '../../../contexts'; +import { useMonitorId } from '../../../hooks'; + +export const useAnomalyAlert = () => { + const { lastRefresh } = useContext(UptimeRefreshContext); + + const dispatch = useDispatch(); + + const monitorId = useMonitorId(); + + const { data: anomalyAlert } = useSelector(alertSelector); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + useEffect(() => { + dispatch(getExistingAlertAction.get({ monitorId })); + }, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]); + + return anomalyAlert; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index df8ceed76b796..29edb69f4674b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -19,10 +19,10 @@ import { selectDurationLines, } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import { JobStat } from '../../../../../ml/public'; import { MonitorDurationComponent } from './monitor_duration'; import { MonitorIdParam } from '../../../../common/types'; +import { getMLJobId } from '../../../../common/lib'; export const MonitorDuration: React.FC = ({ monitorId }) => { const { diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx index 0ae8c3a93da94..b5ef240e67dbf 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx @@ -14,8 +14,8 @@ interface AlertExpressionPopoverProps { 'data-test-subj': string; isEnabled?: boolean; id: string; + value: string | JSX.Element; isInvalid?: boolean; - value: string; } const getColor = ( diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx new file mode 100644 index 0000000000000..4b84012575ae9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx @@ -0,0 +1,86 @@ +/* + * 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 { + EuiExpression, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiHealth, + EuiText, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { AnomalyTranslations } from './translations'; +import { AlertExpressionPopover } from '../alert_expression_popover'; +import { DEFAULT_SEVERITY, SelectSeverity } from './select_severity'; +import { monitorIdSelector } from '../../../../state/selectors'; +import { getSeverityColor, getSeverityType } from '../../../../../../ml/public'; + +interface Props { + alertParams: { [key: string]: any }; + setAlertParams: (key: string, value: any) => void; +} + +// eslint-disable-next-line import/no-default-export +export default function AnomalyAlertComponent({ setAlertParams, alertParams }: Props) { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const monitorIdStore = useSelector(monitorIdSelector); + + const monitorId = monitorIdStore || alertParams?.monitorId; + + useEffect(() => { + setAlertParams('monitorId', monitorId); + }, [monitorId, setAlertParams]); + + useEffect(() => { + setAlertParams('severity', severity.val); + }, [severity, setAlertParams]); + + return ( + <> + + + + +
{monitorId}
+ + } + /> +
+ + + } + data-test-subj={'uptimeAnomalySeverity'} + description={AnomalyTranslations.hasAnomalyWithSeverity} + id="severity" + value={ + + {getSeverityType(severity.val)} + + } + isEnabled={true} + /> + +
+ + + ); +} diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx new file mode 100644 index 0000000000000..0932d0c6eca8d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx @@ -0,0 +1,135 @@ +/* + * 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 React, { Fragment, FC, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { getSeverityColor } from '../../../../../../ml/public'; + +const warningLabel = i18n.translate('xpack.uptime.controls.selectSeverity.warningLabel', { + defaultMessage: 'warning', +}); +const minorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.minorLabel', { + defaultMessage: 'minor', +}); +const majorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.majorLabel', { + defaultMessage: 'major', +}); +const criticalLabel = i18n.translate('xpack.uptime.controls.selectSeverity.criticalLabel', { + defaultMessage: 'critical', +}); + +const optionsMap = { + [warningLabel]: 0, + [minorLabel]: 25, + [majorLabel]: 50, + [criticalLabel]: 75, +}; + +interface TableSeverity { + val: number; + display: string; + color: string; +} + +export const SEVERITY_OPTIONS: TableSeverity[] = [ + { + val: 0, + display: warningLabel, + color: getSeverityColor(0), + }, + { + val: 25, + display: minorLabel, + color: getSeverityColor(25), + }, + { + val: 50, + display: majorLabel, + color: getSeverityColor(50), + }, + { + val: 75, + display: criticalLabel, + color: getSeverityColor(75), + }, +]; + +function optionValueToThreshold(value: number) { + // Get corresponding threshold object with required display and val properties from the specified value. + let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); + + // Default to warning if supplied value doesn't map to one of the options. + if (threshold === undefined) { + threshold = SEVERITY_OPTIONS[0]; + } + + return threshold; +} + +export const DEFAULT_SEVERITY = SEVERITY_OPTIONS[3]; + +const getSeverityOptions = () => + SEVERITY_OPTIONS.map(({ color, display, val }) => ({ + 'data-test-subj': `alertAnomaly${display}`, + value: display, + inputDisplay: ( + + + {display} + + + ), + dropdownDisplay: ( + + + {display} + + + +

+ +

+
+
+ ), + })); + +interface Props { + onChange: (sev: TableSeverity) => void; + value: TableSeverity; +} + +export const SelectSeverity: FC = ({ onChange, value }) => { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const onSeverityChange = (valueDisplay: string) => { + const option = optionValueToThreshold(optionsMap[valueDisplay]); + setSeverity(option); + onChange(option); + }; + + useEffect(() => { + setSeverity(value); + }, [value]); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts new file mode 100644 index 0000000000000..5fd37609f86bf --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts @@ -0,0 +1,26 @@ +/* + * 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 AnomalyTranslations = { + criteriaAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for a selected monitor.', + }), + whenMonitor: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.description', { + defaultMessage: 'When monitor', + }), + scoreAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.scoreExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for an anomaly alert threshold.', + }), + hasAnomalyWithSeverity: i18n.translate( + 'xpack.uptime.alerts.anomaly.scoreExpression.description', + { + defaultMessage: 'has anomaly with severity', + description: 'An expression displaying the criteria for an anomaly alert threshold.', + } + ), +}; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx new file mode 100644 index 0000000000000..f0eb305461582 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; +import { CLIENT_ALERT_TYPES } from '../../../common/constants'; +import { DurationAnomalyTranslations } from './translations'; +import { AlertTypeInitializer } from '.'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { store } from '../../state'; + +const { name, defaultActionMessage } = DurationAnomalyTranslations; +const AnomalyAlertExpression = React.lazy(() => + import('../../components/overview/alerts/anomaly_alert/anomaly_alert') +); +export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ + core, + plugins, +}): AlertTypeModel => ({ + id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, + iconClass: 'uptimeApp', + alertParamsExpression: (params: any) => ( + + + + + + ), + name, + validate: () => ({ errors: {} }), + defaultActionMessage, + requiresAppContext: false, +}); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/plugins/uptime/public/lib/alert_types/index.ts index f2f72311d2262..5eb693c6bd5c3 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/index.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/index.ts @@ -9,6 +9,7 @@ import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { initMonitorStatusAlertType } from './monitor_status'; import { initTlsAlertType } from './tls'; import { ClientPluginsStart } from '../../apps/plugin'; +import { initDurationAnomalyAlertType } from './duration_anomaly'; export type AlertTypeInitializer = (dependenies: { core: CoreStart; @@ -18,4 +19,5 @@ export type AlertTypeInitializer = (dependenies: { export const alertTypeInitializers: AlertTypeInitializer[] = [ initMonitorStatusAlertType, initTlsAlertType, + initDurationAnomalyAlertType, ]; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts index 11fa70bc56f4a..9232dd590ad5e 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts @@ -26,7 +26,7 @@ export const TlsTranslations = { {expiringConditionalOpen} Expiring cert count: {expiringCount} Expiring Certificates: {expiringCommonNameAndDate} -{expiringConditionalClose} +{expiringConditionalClose} {agingConditionalOpen} Aging cert count: {agingCount} @@ -49,3 +49,23 @@ Aging Certificates: {agingCommonNameAndDate} defaultMessage: 'Uptime TLS', }), }; + +export const DurationAnomalyTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.durationAnomaly.defaultActionMessage', { + defaultMessage: `Abnormal ({severity} level) response time detected on {monitor} with url {monitorUrl} at {anomalyStartTimestamp}. Anomaly severity score is {severityScore}. +Response times as high as {slowestAnomalyResponse} have been detected from location {observerLocation}. Expected response time is {expectedResponseTime}.`, + values: { + severity: '{{state.severity}}', + anomalyStartTimestamp: '{{state.anomalyStartTimestamp}}', + monitor: '{{state.monitor}}', + monitorUrl: '{{{state.monitorUrl}}}', + slowestAnomalyResponse: '{{state.slowestAnomalyResponse}}', + expectedResponseTime: '{{state.expectedResponseTime}}', + severityScore: '{{state.severityScore}}', + observerLocation: '{{state.observerLocation}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.durationAnomaly.clientName', { + defaultMessage: 'Uptime Duration Anomaly', + }), +}; diff --git a/x-pack/plugins/uptime/public/pages/monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor.tsx index ab7cf5b2cb3e2..f7012fc5119e9 100644 --- a/x-pack/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor.tsx @@ -16,6 +16,7 @@ import { MonitorCharts } from '../components/monitor'; import { MonitorStatusDetails, PingList } from '../components/monitor'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { Ping } from '../../common/runtime_types/ping'; +import { setSelectedMonitorId } from '../state/actions'; const isAutogeneratedId = (id: string) => { const autoGeneratedId = /^auto-(icmp|http|tcp)-OX[A-F0-9]{16}-[a-f0-9]{16}/; @@ -43,6 +44,10 @@ export const MonitorPage: React.FC = () => { const monitorId = useMonitorId(); + useEffect(() => { + dispatch(setSelectedMonitorId(monitorId)); + }, [monitorId, dispatch]); + const selectedMonitor = useSelector(monitorStatusSelector); useTrackPageview({ app: 'uptime', path: 'monitor' }); diff --git a/x-pack/plugins/uptime/public/state/actions/alerts.ts b/x-pack/plugins/uptime/public/state/actions/alerts.ts new file mode 100644 index 0000000000000..a650a9ba8d08b --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/alerts.ts @@ -0,0 +1,15 @@ +/* + * 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 { createAsyncAction } from './utils'; +import { MonitorIdParam } from './types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const getExistingAlertAction = createAsyncAction( + 'GET EXISTING ALERTS' +); + +export const deleteAlertAction = createAsyncAction<{ alertId: string }, any>('DELETE ALERTS'); diff --git a/x-pack/plugins/uptime/public/state/actions/ui.ts b/x-pack/plugins/uptime/public/state/actions/ui.ts index 04ad6c2fa0bf3..9387506e4e7b5 100644 --- a/x-pack/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/plugins/uptime/public/state/actions/ui.ts @@ -25,3 +25,5 @@ export const setSearchTextAction = createAction('SET SEARCH'); export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); + +export const setSelectedMonitorId = createAction('SET MONITOR ID'); diff --git a/x-pack/plugins/uptime/public/state/api/alerts.ts b/x-pack/plugins/uptime/public/state/api/alerts.ts new file mode 100644 index 0000000000000..526abd6b303e5 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/alerts.ts @@ -0,0 +1,27 @@ +/* + * 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 { apiService } from './utils'; +import { API_URLS } from '../../../common/constants'; +import { MonitorIdParam } from '../actions/types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const fetchAlertRecords = async ({ monitorId }: MonitorIdParam): Promise => { + const data = { + page: 1, + per_page: 500, + filter: 'alert.attributes.alertTypeId:(xpack.uptime.alerts.durationAnomaly)', + default_search_operator: 'AND', + sort_field: 'name.keyword', + sort_order: 'asc', + }; + const alerts = await apiService.get(API_URLS.ALERTS_FIND, data); + return alerts.data.find((alert: Alert) => alert.params.monitorId === monitorId); +}; + +export const disableAnomalyAlert = async ({ alertId }: { alertId: string }) => { + return await apiService.delete(API_URLS.ALERT + alertId); +}; diff --git a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts index 5ec7a6262db66..1d25f35e8f38a 100644 --- a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts @@ -7,38 +7,19 @@ import moment from 'moment'; import { apiService } from './utils'; import { AnomalyRecords, AnomalyRecordsParams } from '../actions'; -import { API_URLS, ML_JOB_ID, ML_MODULE_ID } from '../../../common/constants'; +import { API_URLS, ML_MODULE_ID } from '../../../common/constants'; import { - MlCapabilitiesResponse, DataRecognizerConfigResponse, JobExistResult, + MlCapabilitiesResponse, } from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults, - MonitorIdParam, HeartbeatIndicesParam, + MonitorIdParam, } from '../actions/types'; - -const getJobPrefix = (monitorId: string) => { - // ML App doesn't support upper case characters in job name - // Also Spaces and the characters / ? , " < > | * are not allowed - // so we will replace all special chars with _ - - const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); - - // ML Job ID can't be greater than 64 length, so will be substring it, and hope - // At such big length, there is minimum chance of having duplicate monitor id - // Subtracting ML_JOB_ID constant as well - const postfix = '_' + ML_JOB_ID; - - if ((prefix + postfix).length > 64) { - return prefix.substring(0, 64 - postfix.length) + '_'; - } - return prefix + '_'; -}; - -export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; +import { getJobPrefix, getMLJobId } from '../../../common/lib/ml'; export const getMLCapabilities = async (): Promise => { return await apiService.get(API_URLS.ML_CAPABILITIES); diff --git a/x-pack/plugins/uptime/public/state/effects/alerts.ts b/x-pack/plugins/uptime/public/state/effects/alerts.ts new file mode 100644 index 0000000000000..5f71b0bea7b2c --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/alerts.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'redux-actions'; +import { call, put, takeLatest, select } from 'redux-saga/effects'; +import { fetchEffectFactory } from './fetch_effect'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { disableAnomalyAlert, fetchAlertRecords } from '../api/alerts'; +import { kibanaService } from '../kibana_service'; +import { monitorIdSelector } from '../selectors'; + +export function* fetchAlertsEffect() { + yield takeLatest( + getExistingAlertAction.get, + fetchEffectFactory( + fetchAlertRecords, + getExistingAlertAction.success, + getExistingAlertAction.fail + ) + ); + + yield takeLatest(String(deleteAlertAction.get), function* (action: Action<{ alertId: string }>) { + try { + const response = yield call(disableAnomalyAlert, action.payload); + yield put(deleteAlertAction.success(response)); + kibanaService.core.notifications.toasts.addSuccess('Alert successfully deleted!'); + const monitorId = yield select(monitorIdSelector); + yield put(getExistingAlertAction.get({ monitorId })); + } catch (err) { + kibanaService.core.notifications.toasts.addError(err, { + title: 'Alert cannot be deleted', + }); + yield put(deleteAlertAction.fail(err)); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index 211067c840d54..b13ba7f1a9107 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -17,6 +17,7 @@ import { fetchMonitorDurationEffect } from './monitor_duration'; import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; +import { fetchAlertsEffect } from './alerts'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -33,4 +34,5 @@ export function* rootEffect() { yield fork(fetchMonitorDurationEffect); yield fork(fetchIndexStatusEffect); yield fork(fetchCertificatesEffect); + yield fork(fetchAlertsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts index a6a376b546ab8..00f8a388c689f 100644 --- a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { takeLatest } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; import { getMLCapabilitiesAction, getExistingMLJobAction, @@ -20,6 +21,9 @@ import { deleteMLJob, getMLCapabilities, } from '../api/ml_anomaly'; +import { deleteAlertAction } from '../actions/alerts'; +import { alertSelector } from '../selectors'; +import { MonitorIdParam } from '../actions/types'; export function* fetchMLJobEffect() { yield takeLatest( @@ -38,10 +42,22 @@ export function* fetchMLJobEffect() { getAnomalyRecordsAction.fail ) ); - yield takeLatest( - deleteMLJobAction.get, - fetchEffectFactory(deleteMLJob, deleteMLJobAction.success, deleteMLJobAction.fail) - ); + + yield takeLatest(String(deleteMLJobAction.get), function* (action: Action) { + try { + const response = yield call(deleteMLJob, action.payload); + yield put(deleteMLJobAction.success(response)); + + // let's delete alert as well if it's there + const { data: anomalyAlert } = yield select(alertSelector); + if (anomalyAlert) { + yield put(deleteAlertAction.get({ alertId: anomalyAlert.id as string })); + } + } catch (err) { + yield put(deleteMLJobAction.fail(err)); + } + }); + yield takeLatest( getMLCapabilitiesAction.get, fetchEffectFactory( diff --git a/x-pack/plugins/uptime/public/state/kibana_service.ts b/x-pack/plugins/uptime/public/state/kibana_service.ts index 4fd2d446daa17..f1eb3af9da667 100644 --- a/x-pack/plugins/uptime/public/state/kibana_service.ts +++ b/x-pack/plugins/uptime/public/state/kibana_service.ts @@ -20,6 +20,10 @@ class KibanaService { apiService.http = this._core.http; } + public get toasts() { + return this._core.notifications.toasts; + } + private constructor() {} static getInstance(): KibanaService { diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index c11b146101d35..040fbf7f4fe0a 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -9,6 +9,7 @@ Object { "id": "popover-2", "open": true, }, + "monitorId": "test", "searchText": "", } `; @@ -19,6 +20,7 @@ Object { "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `; diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 4683c654270db..c265cd9fc7ecd 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -24,6 +24,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -43,6 +44,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -59,6 +61,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -68,6 +71,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `); @@ -83,6 +87,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -92,6 +97,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "lorem ipsum", } `); diff --git a/x-pack/plugins/uptime/public/state/reducers/alerts.ts b/x-pack/plugins/uptime/public/state/reducers/alerts.ts new file mode 100644 index 0000000000000..a2cd844e24964 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/alerts.ts @@ -0,0 +1,29 @@ +/* + * 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 { handleActions } from 'redux-actions'; +import { getAsyncInitialState, handleAsyncAction } from './utils'; +import { AsyncInitialState } from './types'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export interface AlertsState { + alert: AsyncInitialState; + alertDeletion: AsyncInitialState; +} + +const initialState: AlertsState = { + alert: getAsyncInitialState(), + alertDeletion: getAsyncInitialState(), +}; + +export const alertsReducer = handleActions( + { + ...handleAsyncAction('alert', getExistingAlertAction), + ...handleAsyncAction('alertDeletion', deleteAlertAction), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index c05c740ab8ebf..01baf7cf07c92 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -20,6 +20,7 @@ import { indexStatusReducer } from './index_status'; import { mlJobsReducer } from './ml_anomaly'; import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; +import { alertsReducer } from './alerts'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -37,4 +38,5 @@ export const rootReducer = combineReducers({ indexStatus: indexStatusReducer, certificates: certificatesReducer, selectedFilters: selectedFiltersReducer, + alerts: alertsReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/ui.ts b/x-pack/plugins/uptime/public/state/reducers/ui.ts index 3cf4ae9c0bbf2..568234a3a83cd 100644 --- a/x-pack/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/plugins/uptime/public/state/reducers/ui.ts @@ -14,6 +14,7 @@ import { setAlertFlyoutType, setAlertFlyoutVisible, setSearchTextAction, + setSelectedMonitorId, } from '../actions'; export interface UiState { @@ -23,6 +24,7 @@ export interface UiState { esKuery: string; searchText: string; integrationsPopoverOpen: PopoverState | null; + monitorId: string; } const initialState: UiState = { @@ -31,6 +33,7 @@ const initialState: UiState = { esKuery: '', searchText: '', integrationsPopoverOpen: null, + monitorId: '', }; export const uiReducer = handleActions( @@ -64,6 +67,10 @@ export const uiReducer = handleActions( ...state, searchText: action.payload, }), + [String(setSelectedMonitorId)]: (state, action: Action) => ({ + ...state, + monitorId: action.payload, + }), }, initialState ); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index b1885ddeeba3f..de8615c7016a7 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -45,6 +45,7 @@ describe('state selectors', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: '', }, monitorStatus: { status: null, @@ -108,6 +109,10 @@ describe('state selectors', () => { }, }, selectedFilters: null, + alerts: { + alertDeletion: { data: null, loading: false }, + alert: { data: null, loading: false }, + }, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 4c2b671203f0a..bf6c9b3666a6a 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -59,6 +59,8 @@ export const hasNewMLJobSelector = ({ ml }: AppState) => ml.createJob; export const isMLJobCreatingSelector = ({ ml }: AppState) => ml.createJob.loading; export const isMLJobDeletingSelector = ({ ml }: AppState) => ml.deleteJob.loading; +export const isAnomalyAlertDeletingSelector = ({ alerts }: AppState) => + alerts.alertDeletion.loading; export const isMLJobDeletedSelector = ({ ml }: AppState) => ml.deleteJob; @@ -88,3 +90,7 @@ export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery; export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText; export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters; + +export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; + +export const alertSelector = ({ alerts }: AppState) => alerts.alert; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 2e732f59e4f30..75d9c8aa959b1 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -14,6 +14,7 @@ import { import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../common/runtime_types'; +import { MlPluginSetup as MlSetup } from '../../../../../ml/server'; export type APICaller = ( endpoint: string, @@ -39,6 +40,7 @@ export interface UptimeCorePlugins { alerts: any; elasticsearch: any; usageCollection: UsageCollectionSetup; + ml: MlSetup; } export interface UMBackendFrameworkAdapter { diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index d85752768b47b..a38132d0f7a83 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -17,7 +17,7 @@ import { GetMonitorStatusResult } from '../../requests'; import { AlertType } from '../../../../../alerts/server'; import { IRouter } from 'kibana/server'; import { UMServerLibs } from '../../lib'; -import { UptimeCoreSetup } from '../../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../../adapters'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; @@ -33,9 +33,10 @@ const bootstrapDependencies = (customRequests?: any) => { // these server/libs parameters don't have any functionality, which is fine // because we aren't testing them here const server: UptimeCoreSetup = { router }; + const plugins: UptimeCorePlugins = {} as any; const libs: UMServerLibs = { requests: {} } as UMServerLibs; libs.requests = { ...libs.requests, ...customRequests }; - return { server, libs }; + return { server, libs, plugins }; }; /** @@ -82,8 +83,8 @@ describe('status check alert', () => { expect.assertions(4); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(mockOptions()); @@ -128,8 +129,8 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions(); const alertServices: AlertServicesMock = options.services; // @ts-ignore the executor can return `void`, but ours never does @@ -213,11 +214,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 4, timerange: { from: 'now-14h', to: 'now' }, @@ -286,11 +287,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 3, timerangeUnit: 'm', @@ -371,11 +372,11 @@ describe('status check alert', () => { toISOStringSpy.mockImplementation(() => 'search test'); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getIndexPattern: jest.fn(), getMonitorStatus: mockGetter, }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 20, timerangeCount: 30, @@ -467,12 +468,12 @@ describe('status check alert', () => { availabilityRatio: 0.909245845760545, }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 35, @@ -559,11 +560,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -600,11 +601,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -748,8 +749,8 @@ describe('status check alert', () => { let alert: AlertType; beforeEach(() => { - const { server, libs } = bootstrapDependencies(); - alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs, plugins); }); it('creates an alert with expected params', () => { diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts new file mode 100644 index 0000000000000..7dd357e99b83d --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -0,0 +1,129 @@ +/* + * 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 moment from 'moment'; +import { schema } from '@kbn/config-schema'; +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { updateState } from './common'; +import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants'; +import { commonStateTranslations, durationAnomalyTranslations } from './translations'; +import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; +import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; +import { getLatestMonitor } from '../requests'; +import { savedObjectsAdapter } from '../saved_objects'; +import { UptimeCorePlugins } from '../adapters/framework'; +import { UptimeAlertTypeFactory } from './types'; +import { Ping } from '../../../common/runtime_types/ping'; +import { getMLJobId } from '../../../common/lib'; + +const { DURATION_ANOMALY } = ACTION_GROUP_DEFINITIONS; + +export const getAnomalySummary = (anomaly: AnomaliesTableRecord, monitorInfo: Ping) => { + return { + severity: getSeverityType(anomaly.severity), + severityScore: Math.round(anomaly.severity), + anomalyStartTimestamp: moment(anomaly.source.timestamp).toISOString(), + monitor: anomaly.source['monitor.id'], + monitorUrl: monitorInfo.url?.full, + slowestAnomalyResponse: Math.round(anomaly.actualSort / 1000) + ' ms', + expectedResponseTime: Math.round(anomaly.typicalSort / 1000) + ' ms', + observerLocation: anomaly.entityValue, + }; +}; + +const getAnomalies = async ( + plugins: UptimeCorePlugins, + mlClusterClient: ILegacyScopedClusterClient, + params: Record, + lastCheckedAt: string +) => { + const { getAnomaliesTableData } = plugins.ml.resultsServiceProvider(mlClusterClient, { + params: 'DummyKibanaRequest', + } as any); + + return await getAnomaliesTableData( + [getMLJobId(params.monitorId)], + [], + [], + 'auto', + params.severity, + moment(lastCheckedAt).valueOf(), + moment().valueOf(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + 500, + 10, + undefined + ); +}; + +export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _libs, plugins) => ({ + id: 'xpack.uptime.alerts.durationAnomaly', + name: durationAnomalyTranslations.alertFactoryName, + validate: { + params: schema.object({ + monitorId: schema.string(), + severity: schema.number(), + }), + }, + defaultActionGroupId: DURATION_ANOMALY.id, + actionGroups: [ + { + id: DURATION_ANOMALY.id, + name: DURATION_ANOMALY.name, + }, + ], + actionVariables: { + context: [], + state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], + }, + producer: 'uptime', + async executor(options) { + const { + services: { + alertInstanceFactory, + callCluster, + savedObjectsClient, + getLegacyScopedClusterClient, + }, + state, + params, + } = options; + + const { anomalies } = + (await getAnomalies( + plugins, + getLegacyScopedClusterClient(plugins.ml.mlClient), + params, + state.lastCheckedAt + )) ?? {}; + + const foundAnomalies = anomalies?.length > 0; + + if (foundAnomalies) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( + savedObjectsClient + ); + const monitorInfo = await getLatestMonitor({ + dynamicSettings, + callES: callCluster, + dateStart: 'now-15m', + dateEnd: 'now', + monitorId: params.monitorId, + }); + anomalies.forEach((anomaly, index) => { + const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); + const summary = getAnomalySummary(anomaly, monitorInfo); + alertInstance.replaceState({ + ...updateState(state, false), + ...summary, + }); + alertInstance.scheduleActions(DURATION_ANOMALY.id); + }); + } + + return updateState(state, foundAnomalies); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts index 661df39ece628..c8d3037f98aeb 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/index.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -7,8 +7,10 @@ import { UptimeAlertTypeFactory } from './types'; import { statusCheckAlertFactory } from './status_check'; import { tlsAlertFactory } from './tls'; +import { durationAnomalyAlertFactory } from './duration_anomaly'; export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [ statusCheckAlertFactory, tlsAlertFactory, + durationAnomalyAlertFactory, ]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/translations.ts b/x-pack/plugins/uptime/server/lib/alerts/translations.ts index e41930aad5af0..50eedcd4fa69e 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/translations.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/translations.ts @@ -148,3 +148,93 @@ export const tlsTranslations = { }, }), }; + +export const durationAnomalyTranslations = { + alertFactoryName: i18n.translate('xpack.uptime.alerts.durationAnomaly', { + defaultMessage: 'Uptime Duration Anomaly', + }), + actionVariables: [ + { + name: 'severity', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severity', + { + defaultMessage: 'The severity of the anomaly.', + } + ), + }, + { + name: 'anomalyStartTimestamp', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.anomalyStartTimestamp', + { + defaultMessage: 'ISO8601 timestamp of the start of the anomaly.', + } + ), + }, + { + name: 'monitor', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitor', + { + defaultMessage: + 'A human friendly rendering of name or ID, preferring name (e.g. My Monitor)', + } + ), + }, + { + name: 'monitorId', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorId', + { + defaultMessage: 'ID of the monitor.', + } + ), + }, + { + name: 'monitorUrl', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorUrl', + { + defaultMessage: 'URL of the monitor.', + } + ), + }, + { + name: 'slowestAnomalyResponse', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.slowestAnomalyResponse', + { + defaultMessage: 'Slowest response time during anomaly bucket with unit (ms, s) attached.', + } + ), + }, + { + name: 'expectedResponseTime', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.expectedResponseTime', + { + defaultMessage: 'Expected response time', + } + ), + }, + { + name: 'severityScore', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severityScore', + { + defaultMessage: 'Anomaly severity score', + } + ), + }, + { + name: 'observerLocation', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.observerLocation', + { + defaultMessage: 'Observer location from which heartbeat check is performed.', + } + ), + }, + ], +}; diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index a321cc124ac22..172930bc3dd3b 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -5,7 +5,11 @@ */ import { AlertType } from '../../../../alerts/server'; -import { UptimeCoreSetup } from '../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; import { UMServerLibs } from '../lib'; -export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; +export type UptimeAlertTypeFactory = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => AlertType; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index fb90dfe2be6c5..afad5896ae64b 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -19,6 +19,6 @@ export const initUptimeServer = ( ); uptimeAlertTypeFactories.forEach((alertTypeFactory) => - plugins.alerts.registerType(alertTypeFactory(server, libs)) + plugins.alerts.registerType(alertTypeFactory(server, libs, plugins)) ); }; diff --git a/x-pack/test/functional/services/uptime/ml_anomaly.ts b/x-pack/test/functional/services/uptime/ml_anomaly.ts index a5f138b7a5716..ac9f6ab2b3d14 100644 --- a/x-pack/test/functional/services/uptime/ml_anomaly.ts +++ b/x-pack/test/functional/services/uptime/ml_anomaly.ts @@ -20,12 +20,18 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { }, async openMLManageMenu() { + await this.cancelAlertFlyout(); return retry.tryForTime(30000, async () => { await testSubjects.click('uptimeManageMLJobBtn'); await testSubjects.existOrFail('uptimeManageMLContextMenu'); }); }, + async cancelAlertFlyout() { + if (await testSubjects.exists('euiFlyoutCloseButton')) + await testSubjects.click('euiFlyoutCloseButton', 60 * 1000); + }, + async alreadyHasJob() { return await testSubjects.exists('uptimeManageMLJobBtn'); }, @@ -55,5 +61,19 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { async hasNoLicenseInfo() { return await testSubjects.missingOrFail('uptimeMLLicenseInfo', { timeout: 1000 }); }, + + async openAlertFlyout() { + return await testSubjects.click('uptimeEnableAnomalyAlertBtn'); + }, + + async disableAnomalyAlertIsVisible() { + return await testSubjects.exists('uptimeDisableAnomalyAlertBtn'); + }, + + async changeAlertThreshold(level: string) { + await testSubjects.click('uptimeAnomalySeverity'); + await testSubjects.click('anomalySeveritySelect'); + await testSubjects.click(`alertAnomaly${level}`); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts new file mode 100644 index 0000000000000..03343bff642c3 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts @@ -0,0 +1,131 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('uptime anomaly alert', () => { + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + const monitorId = '0000-intermittent'; + + const uptime = getService('uptime'); + + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + let alerts: any; + const alertId = 'uptime-anomaly-alert'; + + before(async () => { + alerts = getService('uptime').alerts; + + await uptime.navigation.goToUptime(); + + await uptime.navigation.loadDataAndGoToMonitorPage( + DEFAULT_DATE_START, + DEFAULT_DATE_END, + monitorId + ); + }); + + it('can delete existing job', async () => { + if (await uptime.ml.alreadyHasJob()) { + await uptime.ml.openMLManageMenu(); + await uptime.ml.deleteMLJob(); + await uptime.navigation.refreshApp(); + } + }); + + it('can open ml flyout', async () => { + await uptime.ml.openMLFlyout(); + }); + + it('has permission to create job', async () => { + expect(uptime.ml.canCreateJob()).to.eql(true); + expect(uptime.ml.hasNoLicenseInfo()).to.eql(false); + }); + + it('can create job successfully', async () => { + await uptime.ml.createMLJob(); + await pageObjects.common.closeToast(); + await uptime.ml.cancelAlertFlyout(); + }); + + it('can open ML Manage Menu', async () => { + await uptime.ml.openMLManageMenu(); + }); + + it('can open anomaly alert flyout', async () => { + await uptime.ml.openAlertFlyout(); + }); + + it('can set alert name', async () => { + await alerts.setAlertName(alertId); + }); + + it('can set alert tags', async () => { + await alerts.setAlertTags(['uptime', 'anomaly-alert']); + }); + + it('can change anomaly alert threshold', async () => { + await uptime.ml.changeAlertThreshold('major'); + }); + + it('can save alert', async () => { + await alerts.clickSaveAlertButton(); + await pageObjects.common.closeToast(); + }); + + it('has created a valid alert with expected parameters', async () => { + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { actions, alertTypeId, consumer, id, params, tags } = alert; + try { + expect(actions).to.eql([]); + expect(alertTypeId).to.eql('xpack.uptime.alerts.durationAnomaly'); + expect(consumer).to.eql('uptime'); + expect(tags).to.eql(['uptime', 'anomaly-alert']); + expect(params.monitorId).to.eql(monitorId); + expect(params.severity).to.eql(50); + } finally { + await supertest.delete(`/api/alerts/alert/${id}`).set('kbn-xsrf', 'true').expect(204); + } + }); + + it('change button to disable anomaly alert', async () => { + await uptime.ml.openMLManageMenu(); + expect(uptime.ml.disableAnomalyAlertIsVisible()).to.eql(true); + }); + + it('can delete job successfully', async () => { + await uptime.ml.deleteMLJob(); + }); + + it('verifies that alert is also deleted', async () => { + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(0); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts index ce91a2a26ce91..3016bd6d68f95 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -22,6 +22,7 @@ export default ({ getService, loadTestFile }: FtrProviderContext) => { after(async () => await esArchiver.unload(ARCHIVE)); loadTestFile(require.resolve('./alert_flyout')); + loadTestFile(require.resolve('./anomaly_alert')); }); }); }; From f0e75e80b5b33a2e9d09ed802a6284e1c2800e42 Mon Sep 17 00:00:00 2001 From: MadameSheema Date: Tue, 14 Jul 2020 19:56:49 +0200 Subject: [PATCH 53/58] updates edit exception text save button (#71684) --- .../exceptions/edit_exception_modal/index.tsx | 4 ++-- .../exceptions/edit_exception_modal/translations.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index cedf5c53e0ddc..73933d483e2cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -198,7 +198,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - {i18n.EDIT_EXCEPTION} + {i18n.EDIT_EXCEPTION_TITLE} {ruleName} @@ -260,7 +260,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.CANCEL} - {i18n.EDIT_EXCEPTION} + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts index b2d01d72131b4..6c5cb733b7a73 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -10,8 +10,15 @@ export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editExce defaultMessage: 'Cancel', }); -export const EDIT_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editException', +export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editExceptionSaveButton', + { + defaultMessage: 'Save', + } +); + +export const EDIT_EXCEPTION_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editExceptionTitle', { defaultMessage: 'Edit Exception', } From d0c9fe92840357b19eaea86d876b5c78b3ec0511 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 14 Jul 2020 19:08:19 +0100 Subject: [PATCH 54/58] merged lodash imports (#71672) This is just a code cleanup. A previous PR accidentally added a second import of the same module into alerts_client.ts. This PR corrects that. --- x-pack/plugins/alerts/server/alerts_client.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index ba832c65319f9..e49745b186bb3 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { omit, isEqual, map } from 'lodash'; +import { omit, isEqual, map, truncate } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Logger, @@ -13,7 +13,6 @@ import { SavedObjectReference, SavedObject, } from 'src/core/server'; -import _ from 'lodash'; import { ActionsClient } from '../../actions/server'; import { Alert, @@ -713,6 +712,6 @@ export class AlertsClient { } private generateAPIKeyName(alertTypeId: string, alertName: string) { - return _.truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); + return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); } } From 23ddd27f941cf0ddbf2494cae8dc77d9892f6e26 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Tue, 14 Jul 2020 14:32:45 -0400 Subject: [PATCH 55/58] [EPM][IngestManager][SecuritySolution] Correctly handle nested types (#71680) * Correctly handling nested types * Correct test names --- .../server/services/epm/fields/field.test.ts | 175 ++++++++++++++++++ .../server/services/epm/fields/field.ts | 19 +- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts index f0ff4c6125452..abd2ba777e516 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts @@ -269,6 +269,181 @@ describe('processFields', () => { expect(processFields(nested)).toEqual(nestedExpanded); }); + test('correctly handles properties of nested and object type fields together', () => { + const fields = [ + { + name: 'a', + type: 'object', + }, + { + name: 'a.b', + type: 'nested', + }, + { + name: 'a.b.c', + type: 'boolean', + }, + { + name: 'a.b.d', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'b', + type: 'group-nested', + fields: [ + { + name: 'c', + type: 'boolean', + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + + test('correctly handles properties of nested and object type fields in large depth', () => { + const fields = [ + { + name: 'a.h-object', + type: 'object', + dynamic: false, + }, + { + name: 'a.b-nested.c-nested', + type: 'nested', + }, + { + name: 'a.b-nested', + type: 'nested', + }, + { + name: 'a', + type: 'object', + }, + { + name: 'a.b-nested.d', + type: 'keyword', + }, + { + name: 'a.b-nested.c-nested.e', + type: 'boolean', + dynamic: true, + }, + { + name: 'a.b-nested.c-nested.f-object', + type: 'object', + }, + { + name: 'a.b-nested.c-nested.f-object.g', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'h-object', + type: 'object', + dynamic: false, + }, + { + name: 'b-nested', + type: 'group-nested', + fields: [ + { + name: 'c-nested', + type: 'group-nested', + fields: [ + { + name: 'e', + type: 'boolean', + dynamic: true, + }, + { + name: 'f-object', + type: 'group', + fields: [ + { + name: 'g', + type: 'keyword', + }, + ], + }, + ], + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + + test('correctly handles properties of nested and object type fields together in different order', () => { + const fields = [ + { + name: 'a.b.c', + type: 'boolean', + }, + { + name: 'a.b', + type: 'nested', + }, + { + name: 'a', + type: 'object', + }, + { + name: 'a.b.d', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'b', + type: 'group-nested', + fields: [ + { + name: 'c', + type: 'boolean', + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + test('correctly handles properties of nested type where nested top level comes second', () => { const nested = [ { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index e7c0eca2a9613..a44e5e4221f9f 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -126,10 +126,21 @@ function dedupFields(fields: Fields): Fields { if ( // only merge if found is a group and field is object, nested, or group. // Or if found is object, or nested, and field is a group. - // This is to avoid merging two objects, or nested, or object with a nested. + // This is to avoid merging two objects, or two nested, or object with a nested. + + // we do not need to check for group-nested in this part because `field` will never have group-nested + // it can only exist on `found` (found.type === 'group' && (field.type === 'object' || field.type === 'nested' || field.type === 'group')) || - ((found.type === 'object' || found.type === 'nested') && field.type === 'group') + // as part of the loop we will be marking found.type as group-nested so found could be group-nested if it was + // already processed. If we had an explicit definition of nested, and it showed up before a descendant field: + // - name: a + // type: nested + // - name: a.b + // type: keyword + // then found.type will be nested and not group-nested because it won't have any fields yet until a.b is processed + ((found.type === 'object' || found.type === 'nested' || found.type === 'group-nested') && + field.type === 'group') ) { // if the new field has properties let's dedup and concat them with the already existing found variable in // the array @@ -148,10 +159,10 @@ function dedupFields(fields: Fields): Fields { // supposed to be `nested` for when the template is actually generated if (found.type === 'nested' || field.type === 'nested') { found.type = 'group-nested'; - } else { - // found was either `group` already or `object` so just set it to `group` + } else if (found.type === 'object') { found.type = 'group'; } + // found.type could be group-nested or group, in those cases just leave it } // we need to merge in other properties (like `dynamic`) that might exist Object.assign(found, importantFieldProps); From 8db71dee09a1a99cb95123a592e68ba57ddf28fa Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Tue, 14 Jul 2020 12:43:08 -0600 Subject: [PATCH 56/58] [DOCS] Clarify 'fields' option in SO.find docs (#71491) --- docs/api/saved-objects/bulk_get.asciidoc | 2 +- docs/api/saved-objects/find.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/saved-objects/bulk_get.asciidoc b/docs/api/saved-objects/bulk_get.asciidoc index eaf91a662849e..1d2c9cc32d431 100644 --- a/docs/api/saved-objects/bulk_get.asciidoc +++ b/docs/api/saved-objects/bulk_get.asciidoc @@ -29,7 +29,7 @@ experimental[] Retrieve multiple {kib} saved objects by ID. (Required, string) ID of the retrieved object. The ID includes the {kib} unique identifier or a custom identifier. `fields`:: - (Optional, array) The fields returned in the object response. + (Optional, array) The fields to return in the `attributes` key of the object response. [[saved-objects-api-bulk-get-response-body]] ==== Response body diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index 93e60be5d4923..e82c4e0c00d11 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -41,7 +41,7 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, array|string) The fields to perform the `simple_query_string` parsed query against. `fields`:: - (Optional, array|string) The fields to return in the response. + (Optional, array|string) The fields to return in the `attributes` key of the response. `sort_field`:: (Optional, string) The field that sorts the response. From 6e30ce1ff2fd0456da6e507674b58e0430ed2266 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 14 Jul 2020 19:45:10 +0100 Subject: [PATCH 57/58] [ML] Fix error toasts shown when starting or editing jobs (#71618) * [ML] Fix error toasts shown when starting or editing jobs * [ML] Adds toast_notification_service.ts file * [ML] Fix Jest and type_check tests * [ML] Alter check for statusCode in error object handling * [ML] Fix errors Jest test --- x-pack/plugins/ml/common/util/errors.test.ts | 2 + x-pack/plugins/ml/common/util/errors.ts | 102 +++++++++++++++--- .../action_delete/action_delete.test.tsx | 6 ++ .../action_delete/use_delete_action.ts | 8 +- .../action_edit/edit_button_flyout.tsx | 14 +-- .../action_start/use_start_action.ts | 5 +- .../analytics_service/delete_analytics.ts | 38 +++---- .../analytics_service/start_analytics.ts | 19 ++-- .../edit_job_flyout/edit_job_flyout.js | 8 +- .../jobs/jobs_list/components/utils.js | 6 +- .../application/services/job_service.js | 26 +++-- .../services/toast_notification_service.ts | 84 +++++++++++++++ .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 14 files changed, 256 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/services/toast_notification_service.ts diff --git a/x-pack/plugins/ml/common/util/errors.test.ts b/x-pack/plugins/ml/common/util/errors.test.ts index 00af27248ccce..0b99799e3b6ec 100644 --- a/x-pack/plugins/ml/common/util/errors.test.ts +++ b/x-pack/plugins/ml/common/util/errors.test.ts @@ -30,6 +30,8 @@ describe('ML - error message utils', () => { const bodyWithStringMsg: MLCustomHttpResponseOptions = { body: { msg: testMsg, + statusCode: 404, + response: `{"error":{"reason":"${testMsg}"}}`, }, statusCode: 404, }; diff --git a/x-pack/plugins/ml/common/util/errors.ts b/x-pack/plugins/ml/common/util/errors.ts index e165e15d7c64e..6c5fa7bd75daf 100644 --- a/x-pack/plugins/ml/common/util/errors.ts +++ b/x-pack/plugins/ml/common/util/errors.ts @@ -41,7 +41,7 @@ export type MLResponseError = msg: string; }; } - | { msg: string }; + | { msg: string; statusCode: number; response: string }; export interface MLCustomHttpResponseOptions< T extends ResponseError | MLResponseError | BoomResponse @@ -53,42 +53,118 @@ export interface MLCustomHttpResponseOptions< statusCode: number; } -export const extractErrorMessage = ( +export interface MLErrorObject { + message: string; + fullErrorMessage?: string; // For use in a 'See full error' popover. + statusCode?: number; +} + +export const extractErrorProperties = ( error: | MLCustomHttpResponseOptions - | undefined | string -): string => { - // extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages + | undefined +): MLErrorObject => { + // extract properties of the error object from within the response error + // coming from Kibana, Elasticsearch, and our own ML messages + let message = ''; + let fullErrorMessage; + let statusCode; if (typeof error === 'string') { - return error; + return { + message: error, + }; + } + if (error?.body === undefined) { + return { + message: '', + }; } - if (error?.body === undefined) return ''; if (typeof error.body === 'string') { - return error.body; + return { + message: error.body, + }; } if ( typeof error.body === 'object' && 'output' in error.body && error.body.output.payload.message ) { - return error.body.output.payload.message; + return { + message: error.body.output.payload.message, + }; + } + + if ( + typeof error.body === 'object' && + 'response' in error.body && + typeof error.body.response === 'string' + ) { + const errorResponse = JSON.parse(error.body.response); + if ('error' in errorResponse && typeof errorResponse === 'object') { + const errorResponseError = errorResponse.error; + if ('reason' in errorResponseError) { + message = errorResponseError.reason; + } + if ('caused_by' in errorResponseError) { + const causedByMessage = JSON.stringify(errorResponseError.caused_by); + // Only add a fullErrorMessage if different to the message. + if (causedByMessage !== message) { + fullErrorMessage = causedByMessage; + } + } + return { + message, + fullErrorMessage, + statusCode: error.statusCode, + }; + } } if (typeof error.body === 'object' && 'msg' in error.body && typeof error.body.msg === 'string') { - return error.body.msg; + return { + message: error.body.msg, + }; } if (typeof error.body === 'object' && 'message' in error.body) { + if ( + 'attributes' in error.body && + typeof error.body.attributes === 'object' && + error.body.attributes.body?.status !== undefined + ) { + statusCode = error.body.attributes.body?.status; + } + if (typeof error.body.message === 'string') { - return error.body.message; + return { + message: error.body.message, + statusCode, + }; } if (!(error.body.message instanceof Error) && typeof (error.body.message.msg === 'string')) { - return error.body.message.msg; + return { + message: error.body.message.msg, + statusCode, + }; } } + // If all else fail return an empty message instead of JSON.stringify - return ''; + return { + message: '', + }; +}; + +export const extractErrorMessage = ( + error: + | MLCustomHttpResponseOptions + | undefined + | string +): string => { + // extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages + const errorObj = extractErrorProperties(error); + return errorObj.message; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx index 8d6272c5df860..6b745a2c5ff3b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx @@ -31,7 +31,13 @@ jest.mock('../../../../../contexts/kibana', () => ({ useMlKibana: () => ({ services: mockCoreServices.createStart(), }), + useNotifications: () => { + return { + toasts: { addSuccess: jest.fn(), addDanger: jest.fn(), addError: jest.fn() }, + }; + }, })); + export const MockI18nService = i18nServiceMock.create(); export const I18nServiceConstructor = jest.fn().mockImplementation(() => MockI18nService); jest.doMock('@kbn/i18n', () => ({ diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts index f924cf3afcba5..4fc7b5e1367c4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts @@ -13,6 +13,7 @@ import { IIndexPattern } from 'src/plugins/data/common'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { useMlKibana } from '../../../../../contexts/kibana'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; import { deleteAnalytics, @@ -37,6 +38,8 @@ export const useDeleteAction = () => { const indexName = item?.config.dest.index ?? ''; + const toastNotificationService = useToastNotificationService(); + const checkIndexPatternExists = async () => { try { const response = await savedObjectsClient.find({ @@ -109,10 +112,11 @@ export const useDeleteAction = () => { deleteAnalyticsAndDestIndex( item, deleteTargetIndex, - indexPatternExists && deleteIndexPattern + indexPatternExists && deleteIndexPattern, + toastNotificationService ); } else { - deleteAnalytics(item); + deleteAnalytics(item, toastNotificationService); } } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx index 4b708d48ca0ec..86b1c879417bb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx @@ -28,11 +28,11 @@ import { import { useMlKibana } from '../../../../../contexts/kibana'; import { ml } from '../../../../../services/ml_api_service'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; import { memoryInputValidator, MemoryInputValidatorResult, } from '../../../../../../../common/util/validators'; -import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { DATA_FRAME_TASK_STATE } from '../analytics_list/common'; import { useRefreshAnalyticsList, @@ -60,6 +60,8 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } } = useMlKibana(); const { refresh } = useRefreshAnalyticsList(); + const toastNotificationService = useToastNotificationService(); + // Disable if mml is not valid const updateButtonDisabled = mmlValidationError !== undefined || maxNumThreads === 0; @@ -113,15 +115,15 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } // eslint-disable-next-line console.error(e); - notifications.toasts.addDanger({ - title: i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', { + toastNotificationService.displayErrorToast( + e, + i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', { defaultMessage: 'Could not save changes to analytics job {jobId}', values: { jobId, }, - }), - text: extractErrorMessage(e), - }); + }) + ); } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts index 8eb6b990827ac..3c1087ff587d8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts @@ -8,6 +8,7 @@ import { useState } from 'react'; import { DataFrameAnalyticsListRow } from '../analytics_list/common'; import { startAnalytics } from '../../services/analytics_service'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; export type StartAction = ReturnType; export const useStartAction = () => { @@ -15,11 +16,13 @@ export const useStartAction = () => { const [item, setItem] = useState(); + const toastNotificationService = useToastNotificationService(); + const closeModal = () => setModalVisible(false); const startAndCloseModal = () => { if (item !== undefined) { setModalVisible(false); - startAnalytics(item); + startAnalytics(item, toastNotificationService); } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts index ebd3fa8982604..7d3ee986a4ef1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts @@ -7,13 +7,17 @@ import { i18n } from '@kbn/i18n'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { ml } from '../../../../../services/ml_api_service'; +import { ToastNotificationService } from '../../../../../services/toast_notification_service'; import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common'; import { isDataFrameAnalyticsFailed, DataFrameAnalyticsListRow, } from '../../components/analytics_list/common'; -export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { +export const deleteAnalytics = async ( + d: DataFrameAnalyticsListRow, + toastNotificationService: ToastNotificationService +) => { const toastNotifications = getToastNotifications(); try { if (isDataFrameAnalyticsFailed(d.stats.state)) { @@ -27,13 +31,11 @@ export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { }) ); } catch (e) { - const error = extractErrorMessage(e); - - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + e, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } @@ -43,7 +45,8 @@ export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { export const deleteAnalyticsAndDestIndex = async ( d: DataFrameAnalyticsListRow, deleteDestIndex: boolean, - deleteDestIndexPattern: boolean + deleteDestIndexPattern: boolean, + toastNotificationService: ToastNotificationService ) => { const toastNotifications = getToastNotifications(); const destinationIndex = Array.isArray(d.config.dest.index) @@ -67,12 +70,11 @@ export const deleteAnalyticsAndDestIndex = async ( ); } if (status.analyticsJobDeleted?.error) { - const error = extractErrorMessage(status.analyticsJobDeleted.error); - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + status.analyticsJobDeleted.error, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } @@ -120,13 +122,11 @@ export const deleteAnalyticsAndDestIndex = async ( ); } } catch (e) { - const error = extractErrorMessage(e); - - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + e, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts index 6513cad808485..dfaac8f391f3c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts @@ -5,29 +5,30 @@ */ import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../../../../util/dependency_cache'; import { ml } from '../../../../../services/ml_api_service'; +import { ToastNotificationService } from '../../../../../services/toast_notification_service'; import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common'; import { DataFrameAnalyticsListRow } from '../../components/analytics_list/common'; -export const startAnalytics = async (d: DataFrameAnalyticsListRow) => { - const toastNotifications = getToastNotifications(); +export const startAnalytics = async ( + d: DataFrameAnalyticsListRow, + toastNotificationService: ToastNotificationService +) => { try { await ml.dataFrameAnalytics.startDataFrameAnalytics(d.config.id); - toastNotifications.addSuccess( + toastNotificationService.displaySuccessToast( i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage', { defaultMessage: 'Request to start data frame analytics {analyticsId} acknowledged.', values: { analyticsId: d.config.id }, }) ); } catch (e) { - toastNotifications.addDanger( - i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred starting the data frame analytics {analyticsId}: {error}', - values: { analyticsId: d.config.id, error: JSON.stringify(e) }, + toastNotificationService.displayErrorToast( + e, + i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsErrorTitle', { + defaultMessage: 'Error starting job', }) ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index 3508d69ee2212..9d0082ffcb568 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -26,7 +26,7 @@ import { JobDetails, Detectors, Datafeed, CustomUrls } from './tabs'; import { saveJob } from './edit_utils'; import { loadFullJob } from '../utils'; import { validateModelMemoryLimit, validateGroupNames, isValidCustomUrls } from '../validate_job'; -import { mlMessageBarService } from '../../../../components/messagebar'; +import { toastNotificationServiceProvider } from '../../../../services/toast_notification_service'; import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -255,6 +255,8 @@ export class EditJobFlyoutUI extends Component { }; const { toasts } = this.props.kibana.services.notifications; + const toastNotificationService = toastNotificationServiceProvider(toasts); + saveJob(this.state.job, newJobData) .then(() => { toasts.addSuccess( @@ -270,7 +272,8 @@ export class EditJobFlyoutUI extends Component { }) .catch((error) => { console.error(error); - toasts.addDanger( + toastNotificationService.displayErrorToast( + error, i18n.translate('xpack.ml.jobsList.editJobFlyout.changesNotSavedNotificationMessage', { defaultMessage: 'Could not save changes to {jobId}', values: { @@ -278,7 +281,6 @@ export class EditJobFlyoutUI extends Component { }, }) ); - mlMessageBarService.notify.error(error); }); }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index 569eca4aba949..6fabd0299a936 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -9,6 +9,7 @@ import { mlMessageBarService } from '../../../components/messagebar'; import rison from 'rison-node'; import { mlJobService } from '../../../services/job_service'; +import { toastNotificationServiceProvider } from '../../../services/toast_notification_service'; import { ml } from '../../../services/ml_api_service'; import { getToastNotifications } from '../../../util/dependency_cache'; import { stringMatch } from '../../../util/string_utils'; @@ -158,8 +159,9 @@ function showResults(resp, action) { if (failures.length > 0) { failures.forEach((f) => { - mlMessageBarService.notify.error(f.result.error); - toastNotifications.addDanger( + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + f.result.error, i18n.translate('xpack.ml.jobsList.actionFailedNotificationMessage', { defaultMessage: '{failureId} failed to {actionText}', values: { diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 6c0f393c267aa..7e90758ffd7db 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -11,10 +11,12 @@ import { i18n } from '@kbn/i18n'; import { ml } from './ml_api_service'; import { mlMessageBarService } from '../components/messagebar'; +import { getToastNotifications } from '../util/dependency_cache'; import { isWebUrl } from '../util/url_utils'; import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; import { TIME_FORMAT } from '../../../common/constants/time_format'; import { parseInterval } from '../../../common/util/parse_interval'; +import { toastNotificationServiceProvider } from '../services/toast_notification_service'; const msgs = mlMessageBarService; let jobs = []; @@ -417,14 +419,21 @@ class JobService { return { success: true }; }) .catch((err) => { - msgs.notify.error( - i18n.translate('xpack.ml.jobService.couldNotUpdateJobErrorMessage', { + // TODO - all the functions in here should just return the error and not + // display the toast, as currently both the component and this service display + // errors, so we end up with duplicate toasts. + const toastNotifications = getToastNotifications(); + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + err, + i18n.translate('xpack.ml.jobService.updateJobErrorTitle', { defaultMessage: 'Could not update job: {jobId}', values: { jobId }, }) ); + console.error('update job', err); - return { success: false, message: err.message }; + return { success: false, message: err }; }); } @@ -436,12 +445,15 @@ class JobService { return { success: true, messages }; }) .catch((err) => { - msgs.notify.error( - i18n.translate('xpack.ml.jobService.jobValidationErrorMessage', { - defaultMessage: 'Job Validation Error: {errorMessage}', - values: { errorMessage: err.message }, + const toastNotifications = getToastNotifications(); + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + err, + i18n.translate('xpack.ml.jobService.validateJobErrorTitle', { + defaultMessage: 'Job Validation Error', }) ); + console.log('validate job', err); return { success: false, diff --git a/x-pack/plugins/ml/public/application/services/toast_notification_service.ts b/x-pack/plugins/ml/public/application/services/toast_notification_service.ts new file mode 100644 index 0000000000000..d93d6833c7cb4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/toast_notification_service.ts @@ -0,0 +1,84 @@ +/* + * 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 { ToastInput, ToastOptions, ToastsStart } from 'kibana/public'; +import { ResponseError } from 'kibana/server'; +import { useMemo } from 'react'; +import { useNotifications } from '../contexts/kibana'; +import { + BoomResponse, + extractErrorProperties, + MLCustomHttpResponseOptions, + MLErrorObject, + MLResponseError, +} from '../../../common/util/errors'; + +export type ToastNotificationService = ReturnType; + +export function toastNotificationServiceProvider(toastNotifications: ToastsStart) { + return { + displaySuccessToast(toastOrTitle: ToastInput, options?: ToastOptions) { + toastNotifications.addSuccess(toastOrTitle, options); + }, + + displayErrorToast(error: any, toastTitle: string) { + const errorObj = this.parseErrorMessage(error); + if (errorObj.fullErrorMessage !== undefined) { + // Provide access to the full error message via the 'See full error' button. + toastNotifications.addError(new Error(errorObj.fullErrorMessage), { + title: toastTitle, + toastMessage: errorObj.message, + }); + } else { + toastNotifications.addDanger( + { + title: toastTitle, + text: errorObj.message, + }, + { toastLifeTimeMs: 30000 } + ); + } + }, + + parseErrorMessage( + error: + | MLCustomHttpResponseOptions + | undefined + | string + | MLResponseError + ): MLErrorObject { + if ( + typeof error === 'object' && + 'response' in error && + typeof error.response === 'string' && + error.statusCode !== undefined + ) { + // MLResponseError which has been received back as part of a 'successful' response + // where the error was passed in a separate property in the response. + const wrapMlResponseError = { + body: error, + statusCode: error.statusCode, + }; + return extractErrorProperties(wrapMlResponseError); + } + + return extractErrorProperties( + error as + | MLCustomHttpResponseOptions + | undefined + | string + ); + }, + }; +} + +/** + * Hook to use {@link ToastNotificationService} in React components. + */ +export function useToastNotificationService(): ToastNotificationService { + const { toasts } = useNotifications(); + return useMemo(() => toastNotificationServiceProvider(toasts), []); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 287cf443b1b07..2a8365a8bc5c9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9597,7 +9597,6 @@ "xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton": "分析ジョブの作成", "xpack.ml.dataframe.analyticsList.deleteActionDisabledToolTipContent": "削除するにはデータフレーム分析を停止してください。", "xpack.ml.dataframe.analyticsList.deleteActionName": "削除", - "xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage": "データフレーム分析{analyticsId}の削除中にエラーが発生しました。{error}", "xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage": "データフレーム分析 {analyticsId} の削除リクエストが受け付けられました。", "xpack.ml.dataframe.analyticsList.deleteModalBody": "この分析ジョブを削除してよろしいですか?この分析ジョブのデスティネーションインデックスとオプションのKibanaインデックスパターンは削除されません。", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "キャンセル", @@ -9621,7 +9620,6 @@ "xpack.ml.dataframe.analyticsList.showDetailsColumn.screenReaderDescription": "このカラムには各ジョブの詳細を示すクリック可能なコントロールが含まれます", "xpack.ml.dataframe.analyticsList.sourceIndex": "ソースインデックス", "xpack.ml.dataframe.analyticsList.startActionName": "開始", - "xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage": "データフレーム分析{analyticsId}の開始中にエラーが発生しました。{error}", "xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage": "データフレーム分析 {analyticsId} の開始リクエストが受け付けられました。", "xpack.ml.dataframe.analyticsList.startModalBody": "データフレーム分析ジョブは、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は分析ジョブを停止してください。この分析ジョブを開始してよろしいですか?", "xpack.ml.dataframe.analyticsList.startModalCancelButton": "キャンセル", @@ -9997,11 +9995,9 @@ "xpack.ml.jobService.couldNotStartDatafeedErrorMessage": "{jobId} のデータフィードを開始できませんでした", "xpack.ml.jobService.couldNotStopDatafeedErrorMessage": "{jobId} のデータフィードを停止できませんでした", "xpack.ml.jobService.couldNotUpdateDatafeedErrorMessage": "データフィードを更新できませんでした: {datafeedId}", - "xpack.ml.jobService.couldNotUpdateJobErrorMessage": "ジョブを更新できませんでした: {jobId}", "xpack.ml.jobService.datafeedsListCouldNotBeRetrievedErrorMessage": "データフィードリストを取得できませんでした", "xpack.ml.jobService.failedJobsLabel": "失敗したジョブ", "xpack.ml.jobService.jobsListCouldNotBeRetrievedErrorMessage": "ジョブリストを取得できませんでした", - "xpack.ml.jobService.jobValidationErrorMessage": "ジョブ検証エラー: {errorMessage}", "xpack.ml.jobService.openJobsLabel": "ジョブを開く", "xpack.ml.jobService.requestMayHaveTimedOutErrorMessage": "リクエストがタイムアウトし、まだバックグラウンドで実行中の可能性があります。", "xpack.ml.jobService.totalJobsLabel": "合計ジョブ数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ea3aa71b154aa..42240203a2eaf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9602,7 +9602,6 @@ "xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton": "创建分析作业", "xpack.ml.dataframe.analyticsList.deleteActionDisabledToolTipContent": "停止数据帧分析,才能将其删除。", "xpack.ml.dataframe.analyticsList.deleteActionName": "删除", - "xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage": "删除数据帧分析 {analyticsId} 时发生错误:{error}", "xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage": "数据帧分析 {analyticsId} 删除请求已确认。", "xpack.ml.dataframe.analyticsList.deleteModalBody": "是否确定要删除此分析作业?分析作业的目标索引和可选 Kibana 索引模式将不会删除。", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "取消", @@ -9626,7 +9625,6 @@ "xpack.ml.dataframe.analyticsList.showDetailsColumn.screenReaderDescription": "此列包含可单击控件,用于显示每个作业的更多详情", "xpack.ml.dataframe.analyticsList.sourceIndex": "源索引", "xpack.ml.dataframe.analyticsList.startActionName": "开始", - "xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage": "启动数据帧分析 {analyticsId} 时发生错误:{error}", "xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage": "数据帧分析 {analyticsId} 启动请求已确认。", "xpack.ml.dataframe.analyticsList.startModalBody": "数据帧分析作业将增加集群的搜索和索引负荷。如果负荷超载,请停止分析作业。是否确定要启动此分析作业?", "xpack.ml.dataframe.analyticsList.startModalCancelButton": "取消", @@ -10002,11 +10000,9 @@ "xpack.ml.jobService.couldNotStartDatafeedErrorMessage": "无法开始 {jobId} 的数据馈送", "xpack.ml.jobService.couldNotStopDatafeedErrorMessage": "无法停止 {jobId} 的数据馈送", "xpack.ml.jobService.couldNotUpdateDatafeedErrorMessage": "无法更新数据馈送:{datafeedId}", - "xpack.ml.jobService.couldNotUpdateJobErrorMessage": "无法更新作业:{jobId}", "xpack.ml.jobService.datafeedsListCouldNotBeRetrievedErrorMessage": "无法检索数据馈送列表", "xpack.ml.jobService.failedJobsLabel": "失败的作业", "xpack.ml.jobService.jobsListCouldNotBeRetrievedErrorMessage": "无法检索作业列表", - "xpack.ml.jobService.jobValidationErrorMessage": "作业验证错误:{errorMessage}", "xpack.ml.jobService.openJobsLabel": "打开的作业", "xpack.ml.jobService.requestMayHaveTimedOutErrorMessage": "请求可能已超时,并可能仍在后台运行。", "xpack.ml.jobService.totalJobsLabel": "总计作业数", From 513d0e09e1583370ad036b83d4503e08b4560098 Mon Sep 17 00:00:00 2001 From: spalger Date: Tue, 14 Jul 2020 11:49:04 -0700 Subject: [PATCH 58/58] skip flaky suite (#71713) --- src/plugins/vis_type_vega/public/vega_visualization.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index a6ad6e4908bb4..108b34b36c66f 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -52,7 +52,8 @@ jest.mock('./lib/vega', () => ({ vegaLite: jest.requireActual('vega-lite'), })); -describe('VegaVisualizations', () => { +// FLAKY: https://github.com/elastic/kibana/issues/71713 +describe.skip('VegaVisualizations', () => { let domNode; let VegaVisualization; let vis;