From 7d51ee00c5e45ce4b061b7819eb92fb58e1b6669 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 5 Jul 2021 10:30:40 +0100 Subject: [PATCH 01/48] [Search Session] Fix dangling search sessions (#102927) --- ...embeddable-public.embeddableeditorstate.md | 1 + ...c.embeddableeditorstate.searchsessionid.md | 13 ++++ ...mbeddable-public.embeddablepackagestate.md | 1 + ....embeddablepackagestate.searchsessionid.md | 13 ++++ .../public/application/dashboard_app.tsx | 8 ++ .../public/application/dashboard_router.tsx | 1 + .../lib/build_dashboard_container.ts | 5 ++ .../application/listing/dashboard_listing.tsx | 5 -- .../application/top_nav/dashboard_top_nav.tsx | 3 +- src/plugins/data/public/public.api.md | 2 +- .../data/public/search/session/mocks.ts | 1 + .../search/session/session_service.test.ts | 70 +++++++++++++++++- .../public/search/session/session_service.ts | 73 +++++++++++++++++++ .../public/lib/actions/edit_panel_action.ts | 1 + .../public/lib/state_transfer/types.ts | 12 +++ src/plugins/embeddable/public/public.api.md | 2 + .../lens/public/app_plugin/mounter.tsx | 6 ++ .../search_sessions_integration/config.ts | 1 + .../apps/dashboard/session_sharing/index.ts | 26 +++++++ .../apps/dashboard/session_sharing/lens.ts | 71 ++++++++++++++++++ 20 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md create mode 100644 docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md create mode 100644 x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts create mode 100644 x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md index 63302f50204fe..b944c9dcc02a2 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md @@ -18,5 +18,6 @@ export interface EmbeddableEditorState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.embeddableid.md) | string | | | [originatingApp](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.originatingapp.md) | string | | +| [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [valueInput](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.valueinput.md) | EmbeddableInput | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md new file mode 100644 index 0000000000000..815055fe9f55d --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddableEditorState](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) > [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.searchsessionid.md) + +## EmbeddableEditorState.searchSessionId property + +Pass current search session id when navigating to an editor, Editors could use it continue previous search session + +Signature: + +```typescript +searchSessionId?: string; +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md index 1c0b1b8bf8b46..b3e851a6d0c30 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md @@ -18,5 +18,6 @@ export interface EmbeddablePackageState | --- | --- | --- | | [embeddableId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.embeddableid.md) | string | | | [input](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.input.md) | Optional<EmbeddableInput, 'id'> | Optional<SavedObjectEmbeddableInput, 'id'> | | +| [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md) | string | Pass current search session id when navigating to an editor, Editors could use it continue previous search session | | [type](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.type.md) | string | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md new file mode 100644 index 0000000000000..3c515b1fb6674 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [EmbeddablePackageState](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) > [searchSessionId](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.searchsessionid.md) + +## EmbeddablePackageState.searchSessionId property + +Pass current search session id when navigating to an editor, Editors could use it continue previous search session + +Signature: + +```typescript +searchSessionId?: string; +``` diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 8db6a0e8a8c7f..638b1c83e9dc6 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -40,6 +40,7 @@ export function DashboardApp({ embeddable, onAppLeave, uiSettings, + data, } = useKibana().services; const kbnUrlStateStorage = useMemo( @@ -98,6 +99,13 @@ export function DashboardApp({ ]); }, [chrome, dashboardState.title, dashboardState.viewMode, redirectTo, savedDashboardId]); + // clear search session when leaving dashboard route + useEffect(() => { + return () => { + data.search.session.clear(); + }; + }, [data.search.session]); + return ( <> {isCompleteDashboardAppState(dashboardAppState) && ( diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index e77353000ced4..cb40b30542869 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -260,6 +260,7 @@ export async function mountApp({ } render(app, element); return () => { + dataStart.search.session.clear(); unlistenParentHistory(); unmountComponentAtNode(element); appUnMounted(); diff --git a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts index cb8c5ac5745e4..8b895d739e2d1 100644 --- a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts @@ -64,6 +64,11 @@ export const buildDashboardContainer = async ({ getLatestDashboardState, canStoreSearchSession: dashboardCapabilities.storeSearchSession, }); + + if (incomingEmbeddable?.searchSessionId) { + session.continue(incomingEmbeddable?.searchSessionId); + } + const searchSessionIdFromURL = getSearchSessionIdFromURL(history); if (searchSessionIdFromURL) { session.restore(searchSessionIdFromURL); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 5557bf25d9d85..7f72c77009cb9 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -87,11 +87,6 @@ export const DashboardListing = ({ }; }, [title, savedObjectsClient, redirectTo, data.query, kbnUrlStateStorage]); - // clear dangling session because they are not required here - useEffect(() => { - data.search.session.clear(); - }, [data.search.session]); - const hideWriteControls = dashboardCapabilities.hideWriteControls; const listingLimit = savedObjects.settings.getListingLimit(); const defaultFilter = title ? `"${title}"` : ''; diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e5f89bd6a8e90..dab74373efef5 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -204,10 +204,11 @@ export function DashboardTopNav({ path, state: { originatingApp: DashboardConstants.DASHBOARDS_ID, + searchSessionId: data.search.session.getSessionId(), }, }); }, - [trackUiMetric, stateTransferService] + [stateTransferService, data.search.session, trackUiMetric] ); const clearAddPanel = useCallback(() => { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 35094fac1cc0f..66d81d058fc77 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2783,7 +2783,7 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/index.ts:433:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:436:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:62:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index 18d32463864e3..dee0216530205 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -47,5 +47,6 @@ export function getSessionServiceMock(): jest.Mocked { isSessionStorageReady: jest.fn(() => true), getSearchSessionIndicatorUiConfig: jest.fn(() => ({ isDisabled: () => ({ disabled: false }) })), hasAccess: jest.fn(() => true), + continue: jest.fn(), }; } diff --git a/src/plugins/data/public/search/session/session_service.test.ts b/src/plugins/data/public/search/session/session_service.test.ts index 7f388a29cd454..c2c4d1540c387 100644 --- a/src/plugins/data/public/search/session/session_service.test.ts +++ b/src/plugins/data/public/search/session/session_service.test.ts @@ -98,12 +98,12 @@ describe('Session service', () => { expect(nowProvider.reset).toHaveBeenCalled(); }); - it("Can clear other apps' session", async () => { + it("Can't clear other apps' session", async () => { sessionService.start(); expect(sessionService.getSessionId()).not.toBeUndefined(); currentAppId$.next('change'); sessionService.clear(); - expect(sessionService.getSessionId()).toBeUndefined(); + expect(sessionService.getSessionId()).not.toBeUndefined(); }); it("Can start a new session in case there is other apps' stale session", async () => { @@ -161,6 +161,72 @@ describe('Session service', () => { }); }); + it('Can continue previous session from another app', async () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.clear(); + currentAppId$.next('change'); + sessionService.continue(sessionId!); + + expect(sessionService.getSessionId()).toBe(sessionId); + }); + + it('Calling clear() more than once still allows previous session from another app to continue', async () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.clear(); + sessionService.clear(); + + currentAppId$.next('change'); + sessionService.continue(sessionId!); + + expect(sessionService.getSessionId()).toBe(sessionId); + }); + + it('Continue drops storage configuration', () => { + sessionService.start(); + const sessionId = sessionService.getSessionId(); + + sessionService.enableStorage({ + getName: async () => 'Name', + getUrlGeneratorData: async () => ({ + urlGeneratorId: 'id', + initialState: {}, + restoreState: {}, + }), + }); + + expect(sessionService.isSessionStorageReady()).toBe(true); + + sessionService.clear(); + + sessionService.continue(sessionId!); + + expect(sessionService.isSessionStorageReady()).toBe(false); + }); + + // it might be that search requests finish after the session is cleared and before it was continued, + // to avoid "infinite loading" state after we continue the session we have to drop pending searches + it('Continue drops client side loading state', async () => { + const sessionId = sessionService.start(); + + sessionService.trackSearch({ abort: () => {} }); + expect(state$.getValue()).toBe(SearchSessionState.Loading); + + sessionService.clear(); // even allow to call clear multiple times + + expect(state$.getValue()).toBe(SearchSessionState.None); + + sessionService.continue(sessionId!); + expect(sessionService.getSessionId()).toBe(sessionId); + + // the original search was never `untracked`, + // but we still consider this a completed session until new search fire + expect(state$.getValue()).toBe(SearchSessionState.Completed); + }); + test('getSearchOptions infers isRestore & isStored from state', async () => { const sessionId = sessionService.start(); const someOtherId = 'some-other-id'; diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 629d76b07d7ca..32cd620a2adb2 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { ConfigSchema } from '../../../config'; import { createSessionStateContainer, SearchSessionState, + SessionStateInternal, SessionMeta, SessionStateContainer, } from './search_session_state'; @@ -35,6 +36,11 @@ export interface TrackSearchDescriptor { abort: () => void; } +/** + * Represents a search session state in {@link SessionService} in any given moment of time + */ +export type SessionSnapshot = SessionStateInternal; + /** * Provide info about current search session to be stored in the Search Session saved object */ @@ -88,6 +94,13 @@ export class SessionService { private toastService?: ToastService; + /** + * Holds snapshot of last cleared session so that it can be continued + * Can be used to re-use a session between apps + * @private + */ + private lastSessionSnapshot?: SessionSnapshot; + constructor( initializerContext: PluginInitializerContext, getStartServices: StartServicesAccessor, @@ -128,6 +141,21 @@ export class SessionService { this.subscription.add( coreStart.application.currentAppId$.subscribe((newAppName) => { this.currentApp = newAppName; + if (!this.getSessionId()) return; + + // Apps required to clean up their sessions before unmounting + // Make sure that apps don't leave sessions open by throwing an error in DEV mode + const message = `Application '${ + this.state.get().appName + }' had an open session while navigating`; + if (initializerContext.env.mode.dev) { + coreStart.fatalErrors.add(message); + } else { + // this should never happen in prod because should be caught in dev mode + // in case this happen we don't want to throw fatal error, as most likely possible bugs are not that critical + // eslint-disable-next-line no-console + console.warn(message); + } }) ); }); @@ -158,6 +186,7 @@ export class SessionService { public destroy() { this.subscription.unsubscribe(); this.clear(); + this.lastSessionSnapshot = undefined; } /** @@ -198,7 +227,9 @@ export class SessionService { */ public start() { if (!this.currentApp) throw new Error('this.currentApp is missing'); + this.state.transitions.start({ appName: this.currentApp }); + return this.getSessionId()!; } @@ -211,10 +242,52 @@ export class SessionService { this.refreshSearchSessionSavedObject(); } + /** + * Continue previous search session + * Can be used to share a running search session between different apps, so they can reuse search cache + * + * This is different from {@link restore} as it reuses search session state and search results held in client memory instead of restoring search results from elasticsearch + * @param sessionId + */ + public continue(sessionId: string) { + if (this.lastSessionSnapshot?.sessionId === sessionId) { + this.state.set({ + ...this.lastSessionSnapshot, + // have to change a name, so that current app can cancel a session that it continues + appName: this.currentApp, + // also have to drop all pending searches which are used to derive client side state of search session indicator, + // if we weren't dropping this searches, then we would get into "infinite loading" state when continuing a session that was cleared with pending searches + // possible solution to this problem is to refactor session service to support multiple sessions + pendingSearches: [], + }); + this.lastSessionSnapshot = undefined; + } else { + // eslint-disable-next-line no-console + console.warn( + `Continue search session: last known search session id: "${this.lastSessionSnapshot?.sessionId}", but received ${sessionId}` + ); + } + } + /** * Cleans up current state */ public clear() { + // make sure apps can't clear other apps' sessions + const currentSessionApp = this.state.get().appName; + if (currentSessionApp && currentSessionApp !== this.currentApp) { + // eslint-disable-next-line no-console + console.warn( + `Skip clearing session "${this.getSessionId()}" because it belongs to a different app. current: "${ + this.currentApp + }", owner: "${currentSessionApp}"` + ); + return; + } + + if (this.getSessionId()) { + this.lastSessionSnapshot = this.state.get(); + } this.state.transitions.clear(); this.searchSessionInfoProvider = undefined; this.searchSessionIndicatorUiConfig = undefined; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 058fd832e15db..ea90307ef57a1 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -111,6 +111,7 @@ export class EditPanelAction implements Action { originatingApp: this.currentAppId, valueInput: byValueMode ? this.getExplicitInput({ embeddable }) : undefined, embeddableId: embeddable.id, + searchSessionId: embeddable.getInput().searchSessionId, }; return { app, path, state }; } diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 5e5ef9c360a64..98cf6e70284cd 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -19,6 +19,12 @@ export interface EmbeddableEditorState { originatingApp: string; embeddableId?: string; valueInput?: EmbeddableInput; + + /** + * Pass current search session id when navigating to an editor, + * Editors could use it continue previous search session + */ + searchSessionId?: string; } export function isEmbeddableEditorState(state: unknown): state is EmbeddableEditorState { @@ -35,6 +41,12 @@ export interface EmbeddablePackageState { type: string; input: Optional | Optional; embeddableId?: string; + + /** + * Pass current search session id when navigating to an editor, + * Editors could use it continue previous search session + */ + searchSessionId?: string; } export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState { diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 98c48dbd848b0..a810b1f48a07c 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -365,6 +365,7 @@ export interface EmbeddableEditorState { embeddableId?: string; // (undocumented) originatingApp: string; + searchSessionId?: string; // (undocumented) valueInput?: EmbeddableInput; } @@ -467,6 +468,7 @@ export interface EmbeddablePackageState { embeddableId?: string; // (undocumented) input: Optional | Optional; + searchSessionId?: string; // (undocumented) type: string; } diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 1fd12460ba3b6..12522402116c9 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -161,6 +161,7 @@ export async function mountApp( embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, type: LENS_EMBEDDABLE_TYPE, input, + searchSessionId: data.search.session.getSessionId(), }, }); } else { @@ -178,6 +179,10 @@ export async function mountApp( if (!initialContext) { data.query.filterManager.setAppFilters([]); } + + if (embeddableEditorIncomingState?.searchSessionId) { + data.search.session.continue(embeddableEditorIncomingState.searchSessionId); + } const { datasourceMap, visualizationMap } = instance; const initialDatasourceId = getInitialDatasourceId(datasourceMap); @@ -298,6 +303,7 @@ export async function mountApp( params.element ); return () => { + data.search.session.clear(); unmountComponentAtNode(params.element); unlistenParentHistory(); lensStore.dispatch(navigateAway()); diff --git a/x-pack/test/search_sessions_integration/config.ts b/x-pack/test/search_sessions_integration/config.ts index 1e2d648712098..9dc542038a48a 100644 --- a/x-pack/test/search_sessions_integration/config.ts +++ b/x-pack/test/search_sessions_integration/config.ts @@ -22,6 +22,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './tests/apps/dashboard/async_search'), + resolve(__dirname, './tests/apps/dashboard/session_sharing'), resolve(__dirname, './tests/apps/discover'), resolve(__dirname, './tests/apps/lens'), resolve(__dirname, './tests/apps/management/search_sessions'), diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.ts new file mode 100644 index 0000000000000..d06d5d5ebd6ab --- /dev/null +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common']); + + describe('Search session sharing', function () { + this.tags('ciGroup3'); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); + await PageObjects.common.navigateToApp('dashboard'); + }); + + loadTestFile(require.resolve('./lens')); + }); +} diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts new file mode 100644 index 0000000000000..b6dfc29bb1c6b --- /dev/null +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/session_sharing/lens.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const find = getService('find'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['header', 'common', 'dashboard', 'timePicker', 'lens']); + + // Dashboard shares a search session with lens when navigating to and from by value lens to hit search cache + // https://github.com/elastic/kibana/issues/99310 + describe('Search session sharing with lens', () => { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/lens/basic'); + }); + + // NOTE: This test doesn't check if the cache was actually hit, but just checks if the same search session id is used + // so it doesn't give the 100% confidence that cache-hit improvement works https://github.com/elastic/kibana/issues/99310 + // but if it fails, we for sure know it doesn't work + it("should share search session with by value lens and don't share with by reference", async () => { + // Add a by ref lens panel to a new dashboard + const lensTitle = 'Artistpreviouslyknownaslens'; + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames(lensTitle); + await find.clickByButtonText(lensTitle); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.dashboard.waitForRenderComplete(); + + // Navigating to lens and back should create a new session + const byRefSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.dashboard.waitForRenderComplete(); + const newByRefSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + + expect(byRefSessionId).not.to.eql(newByRefSessionId); + + // Convert to by-value + const byRefPanel = await testSubjects.find('embeddablePanelHeading-' + lensTitle); + await dashboardPanelActions.unlinkFromLibary(byRefPanel); + await PageObjects.dashboard.waitForRenderComplete(); + const byValueSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + + // Navigating to lens and back should keep the session + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.dashboard.waitForRenderComplete(); + const newByValueSessionId = await dashboardPanelActions.getSearchSessionIdByTitle(lensTitle); + expect(byValueSessionId).to.eql(newByValueSessionId); + }); + }); +} From e5954a65ac4164d4cde476a1fc422866f2cdb82f Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Mon, 5 Jul 2021 10:50:08 +0100 Subject: [PATCH 02/48] [SecuritySolution] Fix inspect button on host details page (#104011) * add test * rename test * update snapshot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../cypress/integration/hosts/inspect.spec.ts | 23 ++++++++++++++++--- .../integration/urls/not_found.spec.ts | 2 +- .../cypress/screens/hosts/main.ts | 2 ++ .../cypress/tasks/inspect.ts | 6 ++++- .../security_solution/cypress/tasks/login.ts | 6 +++++ .../cypress/urls/navigation.ts | 2 ++ .../public/hosts/pages/details/index.tsx | 3 ++- .../components/host_overview/index.tsx | 5 +++- .../expandable_host.test.tsx.snap | 3 +++ 9 files changed, 45 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts index 28240bd12e4be..4a729ab5044ba 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/inspect.spec.ts @@ -6,17 +6,20 @@ */ import { INSPECT_HOSTS_BUTTONS_IN_SECURITY, INSPECT_MODAL } from '../../screens/inspect'; +import { HOST_OVERVIEW } from '../../screens/hosts/main'; import { cleanKibana } from '../../tasks/common'; -import { closesModal, openStatsAndTables } from '../../tasks/inspect'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { clickInspectButton, closesModal, openStatsAndTables } from '../../tasks/inspect'; +import { loginAndWaitForHostDetailsPage, loginAndWaitForPage } from '../../tasks/login'; import { HOSTS_URL } from '../../urls/navigation'; describe('Inspect', () => { + before(() => { + cleanKibana(); + }); context('Hosts stats and tables', () => { before(() => { - cleanKibana(); loginAndWaitForPage(HOSTS_URL); }); afterEach(() => { @@ -30,4 +33,18 @@ describe('Inspect', () => { }) ); }); + + context('Hosts details', () => { + before(() => { + loginAndWaitForHostDetailsPage(); + }); + afterEach(() => { + closesModal(); + }); + + it(`inspects the host details`, () => { + clickInspectButton(HOST_OVERVIEW); + cy.get(INSPECT_MODAL).should('be.visible'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts index 3b1df67bec29c..cdccb6d75d18c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/urls/not_found.spec.ts @@ -71,7 +71,7 @@ describe('Display not found page', () => { cy.get(NOT_FOUND).should('exist'); }); - it('navigates to the trusted applications page with incorrect link', () => { + it('navigates to the event filters page with incorrect link', () => { loginAndWaitForPage(`${EVENT_FILTERS_URL}/randomUrl`); cy.get(NOT_FOUND).should('exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts index f8b20393ba110..95381b06f44e9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/main.ts @@ -16,3 +16,5 @@ export const EVENTS_TAB = '[data-test-subj="navigation-events"]'; export const KQL_SEARCH_BAR = '[data-test-subj="queryInput"]'; export const UNCOMMON_PROCESSES_TAB = '[data-test-subj="navigation-uncommonProcesses"]'; + +export const HOST_OVERVIEW = `[data-test-subj="host-overview"]`; diff --git a/x-pack/plugins/security_solution/cypress/tasks/inspect.ts b/x-pack/plugins/security_solution/cypress/tasks/inspect.ts index d056e012bb7f8..112bf9a208b39 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/inspect.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/inspect.ts @@ -11,6 +11,10 @@ export const closesModal = () => { cy.get('[data-test-subj="modal-inspect-close"]').click(); }; +export const clickInspectButton = (container: string) => { + cy.get(`${container} ${INSPECT_BUTTON_ICON}`).trigger('click', { force: true }); +}; + export const openStatsAndTables = (table: InspectButtonMetadata) => { if (table.tabId) { cy.get(table.tabId).click({ force: true }); @@ -21,6 +25,6 @@ export const openStatsAndTables = (table: InspectButtonMetadata) => { force: true, }); } else { - cy.get(`${table.id} ${INSPECT_BUTTON_ICON}`).trigger('click', { force: true }); + clickInspectButton(table.id); } }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index be447993273fb..243bfd113bfd2 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -10,6 +10,7 @@ import Url, { UrlObject } from 'url'; import { ROLES } from '../../common/test'; import { TIMELINE_FLYOUT_BODY } from '../screens/timeline'; +import { hostDetailsUrl } from '../urls/navigation'; /** * Credentials in the `kibana.dev.yml` config file will be used to authenticate @@ -312,6 +313,11 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { cy.get(TIMELINE_FLYOUT_BODY).should('be.visible'); }; +export const loginAndWaitForHostDetailsPage = () => { + loginAndWaitForPage(hostDetailsUrl('suricata-iowa')); + cy.get('[data-test-subj="loading-spinner"]', { timeout: 12000 }).should('not.exist'); +}; + export const waitForPageWithoutDateRange = (url: string, role?: ROLES) => { cy.visit(role ? getUrlWithRoute(role, url) : url); cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index 304db7e93e2cb..a9fad865f506c 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -23,6 +23,8 @@ export const SECURITY_DETECTIONS_RULES_CREATION_URL = '/app/security/detections/ export const EXCEPTIONS_URL = 'app/security/exceptions'; export const HOSTS_URL = '/app/security/hosts/allHosts'; +export const hostDetailsUrl = (hostName: string) => + `/app/security/hosts/${hostName}/authentications`; export const HOSTS_PAGE_TAB_URLS = { allHosts: '/app/security/hosts/allHosts', anomalies: '/app/security/hosts/anomalies', diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 6e371bbf610e1..7c34e6f30b910 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -97,7 +97,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta ); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); - const [loading, { hostDetails: hostOverview, id, refetch }] = useHostDetails({ + const [loading, { inspect, hostDetails: hostOverview, id, refetch }] = useHostDetails({ endDate: to, startDate: from, hostName: detailName, @@ -169,6 +169,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta }} setQuery={setQuery} refetch={refetch} + inspect={inspect} /> )} diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index fa644d1cbcdac..75723e0d3af84 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -203,7 +203,10 @@ export const HostOverview = React.memo( return ( <> - + {!isInDetailsSidePanel && ( )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap index 84611e0b7f02c..2cd3d333798d4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/__snapshots__/expandable_host.test.tsx.snap @@ -121,14 +121,17 @@ exports[`Expandable Host Component ExpandableHostDetails: rendering it should re className="c0" >
Date: Mon, 5 Jul 2021 14:02:21 +0200 Subject: [PATCH 03/48] [Security Solutions] Navigation minor code issues fixed (#103938) * make formatUrl allow any pageId and not only main navigation pages. types refactoring * deepLinks updateGlobalNavigation tuned * new getScopeFromPath sourcerer function to initialize from pathname * improve useNavigation hooks to be used in more places * deep links paths fixed * translations updated Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/common/constants.ts | 7 -- .../public/app/deep_links/index.ts | 38 +++--- .../public/app/home/home_navigations.ts | 42 ++++--- .../public/app/home/index.tsx | 16 ++- .../template_wrapper/bottom_bar/index.tsx | 8 +- .../security_solution/public/app/types.ts | 2 +- .../public/cases/pages/configure_cases.tsx | 5 +- .../public/cases/pages/create_case.tsx | 5 +- .../components/header_page/index.test.tsx | 2 +- .../common/components/header_page/index.tsx | 113 +++++++++--------- .../public/common/components/link_to/index.ts | 18 +-- .../common/components/navigation/types.ts | 22 ++-- .../navigation/use_get_url_search.tsx | 4 +- .../index.test.tsx | 6 +- .../use_navigation_items.tsx | 63 +++++----- .../containers/sourcerer/index.test.tsx | 17 ++- .../common/containers/sourcerer/index.tsx | 13 ++ .../public/common/lib/kibana/hooks.ts | 16 ++- .../alerts_histogram_panel/index.test.tsx | 11 +- .../load_empty_prompt.test.tsx | 15 ++- .../pre_packaged_rules/load_empty_prompt.tsx | 9 +- .../detection_engine/rules/create/index.tsx | 2 +- .../detection_engine/rules/details/index.tsx | 2 +- .../detection_engine/rules/edit/index.tsx | 2 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 26 files changed, 241 insertions(+), 199 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 3fb32856a1ef1..abfcb4014a79f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -78,13 +78,6 @@ export enum SecurityPageName { eventFilters = 'event_filters', } -export enum SecurityPageGroupName { - detect = 'detect', - explore = 'explore', - investigate = 'investigate', - manage = 'manage', -} - export const TIMELINES_PATH = '/timelines'; export const CASES_PATH = '/cases'; export const OVERVIEW_PATH = '/overview'; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index affed45908fdd..e1c14f2a86380 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -26,7 +26,7 @@ import { NETWORK, TIMELINES, CASE, - ADMINISTRATION, + MANAGE, } from '../translations'; import { OVERVIEW_PATH, @@ -116,12 +116,12 @@ export const topDeepLinks: AppDeepLink[] = [ }, { id: SecurityPageName.administration, - title: ADMINISTRATION, + title: MANAGE, path: ENDPOINTS_PATH, navLinkStatus: AppNavLinkStatus.hidden, keywords: [ - i18n.translate('xpack.securitySolution.search.administration', { - defaultMessage: 'Administration', + i18n.translate('xpack.securitySolution.search.manage', { + defaultMessage: 'Manage', }), ], }, @@ -179,28 +179,28 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.authentications', { defaultMessage: 'Authentications', }), - path: '/authentications', + path: `${HOSTS_PATH}/authentications`, }, { id: 'uncommonProcesses', title: i18n.translate('xpack.securitySolution.search.hosts.uncommonProcesses', { defaultMessage: 'Uncommon Processes', }), - path: '/uncommonProcesses', + path: `${HOSTS_PATH}/uncommonProcesses`, }, { id: 'events', title: i18n.translate('xpack.securitySolution.search.hosts.events', { defaultMessage: 'Events', }), - path: '/events', + path: `${HOSTS_PATH}/events`, }, { id: 'externalAlerts', title: i18n.translate('xpack.securitySolution.search.hosts.externalAlerts', { defaultMessage: 'External Alerts', }), - path: '/alerts', + path: `${HOSTS_PATH}/alerts`, }, ], premium: [ @@ -209,7 +209,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.anomalies', { defaultMessage: 'Anomalies', }), - path: '/anomalies', + path: `${HOSTS_PATH}/anomalies`, }, ], }, @@ -220,28 +220,28 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.network.dns', { defaultMessage: 'DNS', }), - path: '/dns', + path: `${NETWORK_PATH}/dns`, }, { id: 'http', title: i18n.translate('xpack.securitySolution.search.network.http', { defaultMessage: 'HTTP', }), - path: '/http', + path: `${NETWORK_PATH}/http`, }, { id: 'tls', title: i18n.translate('xpack.securitySolution.search.network.tls', { defaultMessage: 'TLS', }), - path: '/tls', + path: `${NETWORK_PATH}/tls`, }, { id: 'externalAlertsNetwork', title: i18n.translate('xpack.securitySolution.search.network.externalAlerts', { defaultMessage: 'External Alerts', }), - path: '/external-alerts', + path: `${NETWORK_PATH}/external-alerts`, }, ], premium: [ @@ -250,7 +250,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.hosts.anomalies', { defaultMessage: 'Anomalies', }), - path: '/anomalies', + path: `${NETWORK_PATH}/anomalies`, }, ], }, @@ -261,7 +261,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.timeline.templates', { defaultMessage: 'Templates', }), - path: '/template', + path: `${TIMELINES_PATH}/template`, }, ], }, @@ -272,7 +272,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.cases.create', { defaultMessage: 'Create New Case', }), - path: '/create', + path: `${CASES_PATH}/create`, }, ], premium: [ @@ -281,7 +281,7 @@ const nestedDeepLinks: SecurityDeepLinks = { title: i18n.translate('xpack.securitySolution.search.cases.configure', { defaultMessage: 'Configure Cases', }), - path: '/configure', + path: `${CASES_PATH}/configure`, }, ], }, @@ -377,12 +377,13 @@ export function updateGlobalNavigation({ const deepLinks = getDeepLinks(undefined, capabilities); const updatedDeepLinks = deepLinks.map((link) => { switch (link.id) { - case 'case': + case SecurityPageName.case: return { ...link, navLinkStatus: capabilities.siem.read_cases ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: capabilities.siem.read_cases === true, }; default: return link; @@ -390,6 +391,7 @@ export function updateGlobalNavigation({ }); updater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent showing main nav link deepLinks: updatedDeepLinks, })); } diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 271eea47840dc..d6f8516d43a72 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -6,8 +6,11 @@ */ import * as i18n from '../translations'; -import { SecurityPageName, SecurityPageGroupName } from '../types'; -import { SiemNavTab, NavTabGroups } from '../../common/components/navigation/types'; +import { + SecurityNav, + SecurityNavGroup, + SecurityNavGroupKey, +} from '../../common/components/navigation/types'; import { APP_OVERVIEW_PATH, APP_RULES_PATH, @@ -21,9 +24,10 @@ import { APP_ENDPOINTS_PATH, APP_TRUSTED_APPS_PATH, APP_EVENT_FILTERS_PATH, + SecurityPageName, } from '../../../common/constants'; -export const navTabs: SiemNavTab = { +export const navTabs: SecurityNav = { [SecurityPageName.overview]: { id: SecurityPageName.overview, name: i18n.OVERVIEW, @@ -36,21 +40,21 @@ export const navTabs: SiemNavTab = { name: i18n.ALERTS, href: APP_ALERTS_PATH, disabled: false, - urlKey: SecurityPageName.alerts, + urlKey: 'alerts', }, [SecurityPageName.rules]: { id: SecurityPageName.rules, name: i18n.RULES, href: APP_RULES_PATH, disabled: false, - urlKey: SecurityPageName.rules, + urlKey: 'rules', }, [SecurityPageName.exceptions]: { id: SecurityPageName.exceptions, name: i18n.EXCEPTIONS, href: APP_EXCEPTIONS_PATH, disabled: false, - urlKey: SecurityPageName.exceptions, + urlKey: 'exceptions', }, [SecurityPageName.hosts]: { id: SecurityPageName.hosts, @@ -85,46 +89,46 @@ export const navTabs: SiemNavTab = { name: i18n.ADMINISTRATION, href: APP_MANAGEMENT_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.endpoints]: { id: SecurityPageName.endpoints, name: i18n.ENDPOINTS, href: APP_ENDPOINTS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.trustedApps]: { id: SecurityPageName.trustedApps, name: i18n.TRUSTED_APPLICATIONS, href: APP_TRUSTED_APPS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, [SecurityPageName.eventFilters]: { id: SecurityPageName.eventFilters, name: i18n.EVENT_FILTERS, href: APP_EVENT_FILTERS_PATH, disabled: false, - urlKey: SecurityPageName.administration, + urlKey: 'administration', }, }; -export const navTabGroups: NavTabGroups = { - [SecurityPageGroupName.detect]: { - id: SecurityPageGroupName.detect, +export const securityNavGroup: SecurityNavGroup = { + [SecurityNavGroupKey.detect]: { + id: SecurityNavGroupKey.detect, name: i18n.DETECT, }, - [SecurityPageGroupName.explore]: { - id: SecurityPageGroupName.explore, + [SecurityNavGroupKey.explore]: { + id: SecurityNavGroupKey.explore, name: i18n.EXPLORE, }, - [SecurityPageGroupName.investigate]: { - id: SecurityPageGroupName.investigate, + [SecurityNavGroupKey.investigate]: { + id: SecurityNavGroupKey.investigate, name: i18n.INVESTIGATE, }, - [SecurityPageGroupName.manage]: { - id: SecurityPageGroupName.manage, + [SecurityNavGroupKey.manage]: { + id: SecurityNavGroupKey.manage, name: i18n.MANAGE, }, }; diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index d16c35a832e6b..e7f825691c58d 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -14,12 +14,14 @@ import { SecuritySolutionAppWrapper } from '../../common/components/page'; import { HelpMenu } from '../../common/components/help_menu'; import { UseUrlState } from '../../common/components/url_state'; import { navTabs } from './home_navigations'; -import { useInitSourcerer, useSourcererScope } from '../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { + useInitSourcerer, + useSourcererScope, + getScopeFromPath, +} from '../../common/containers/sourcerer'; import { useUpgradeSecurityPackages } from '../../common/hooks/use_upgrade_security_packages'; import { GlobalHeader } from './global_header'; import { SecuritySolutionTemplateWrapper } from './template_wrapper'; -import { isDetectionsPath } from '../../helpers'; interface HomePageProps { children: React.ReactNode; @@ -34,13 +36,9 @@ const HomePageComponent: React.FC = ({ }) => { const { pathname } = useLocation(); - useInitSourcerer( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + useInitSourcerer(getScopeFromPath(pathname)); - const { browserFields, indexPattern } = useSourcererScope( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + const { browserFields, indexPattern } = useSourcererScope(getScopeFromPath(pathname)); // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx index a2f1ed8c115d6..eb606cd8ff583 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx @@ -12,12 +12,10 @@ import { useLocation } from 'react-router-dom'; import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; import { AppLeaveHandler } from '../../../../../../../../src/core/public'; import { useShowTimeline } from '../../../../common/utils/timeline/use_show_timeline'; -import { useSourcererScope } from '../../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { useSourcererScope, getScopeFromPath } from '../../../../common/containers/sourcerer'; import { TimelineId } from '../../../../../common/types/timeline'; import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning'; import { Flyout } from '../../../../timelines/components/flyout'; -import { isDetectionsPath } from '../../../../../public/helpers'; export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar'; @@ -27,9 +25,7 @@ export const SecuritySolutionBottomBar = React.memo( const [showTimeline] = useShowTimeline(); - const { indicesExist } = useSourcererScope( - isDetectionsPath(pathname) ? SourcererScopeName.detections : SourcererScopeName.default - ); + const { indicesExist } = useSourcererScope(getScopeFromPath(pathname)); return indicesExist && showTimeline ? ( <> diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index 62a61828830be..8056c4092091c 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -36,7 +36,7 @@ import { Immutable } from '../../common/endpoint/types'; import { AppAction } from '../common/store/actions'; import { TimelineState } from '../timelines/store/timeline/types'; import { SecurityPageName } from '../../common/constants'; -export { SecurityPageName, SecurityPageGroupName } from '../../common/constants'; +export { SecurityPageName } from '../../common/constants'; export interface SecuritySubPluginStore { initialState: Record; diff --git a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx index 13a549b6babc9..c5ed3454f1ca5 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx @@ -19,7 +19,6 @@ import { CaseHeaderPage } from '../components/case_header_page'; import { WhitePageWrapper, SectionWrapper } from '../components/wrappers'; import * as i18n from './translations'; import { APP_ID } from '../../../common/constants'; -import { SiemNavTabKey } from '../../common/components/navigation/types'; const ConfigureCasesPageComponent: React.FC = () => { const { @@ -31,9 +30,9 @@ const ConfigureCasesPageComponent: React.FC = () => { const backOptions = useMemo( () => ({ - href: getCaseUrl(search), + path: getCaseUrl(search), text: i18n.BACK_TO_ALL, - pageId: SecurityPageName.case as SiemNavTabKey, + pageId: SecurityPageName.case, }), [search] ); diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index e46e5c2074f05..4a59fe3fdcabd 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -18,7 +18,6 @@ import { CaseHeaderPage } from '../components/case_header_page'; import { Create } from '../components/create'; import * as i18n from './translations'; import { APP_ID } from '../../../common/constants'; -import { SiemNavTabKey } from '../../common/components/navigation/types'; export const CreateCasePage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); @@ -29,9 +28,9 @@ export const CreateCasePage = React.memo(() => { const backOptions = useMemo( () => ({ - href: getCaseUrl(search), + path: getCaseUrl(search), text: i18n.BACK_TO_ALL, - pageId: SecurityPageName.case as SiemNavTabKey, + pageId: SecurityPageName.case, }), [search] ); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx index 2c42353daee75..47b0871229864 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx @@ -41,7 +41,7 @@ describe('HeaderPage', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index dc8e19249b6be..dea19e1366875 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -12,7 +12,7 @@ import { EuiPageHeaderSection, EuiSpacer, } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { LinkIcon, LinkIconProps } from '../link_icon'; @@ -24,8 +24,6 @@ import { SecurityPageName } from '../../../app/types'; import { Sourcerer } from '../sourcerer'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useKibana } from '../../lib/kibana'; -import { SiemNavTabKey } from '../navigation/types'; - interface HeaderProps { border?: boolean; isLoading?: boolean; @@ -65,10 +63,10 @@ const HeaderSection = styled(EuiPageHeaderSection)` HeaderSection.displayName = 'HeaderSection'; interface BackOptions { + pageId: SecurityPageName; text: LinkIconProps['children']; - href?: LinkIconProps['href']; + path?: string; dataTestSubj?: string; - pageId: SiemNavTabKey; } export interface HeaderPageProps extends HeaderProps { @@ -85,6 +83,29 @@ export interface HeaderPageProps extends HeaderProps { titleNode?: React.ReactElement; } +const HeaderLinkBack: React.FC<{ backOptions: BackOptions }> = React.memo(({ backOptions }) => { + const { navigateToUrl } = useKibana().services.application; + const { formatUrl } = useFormatUrl(backOptions.pageId); + + const backUrl = formatUrl(backOptions.path ?? ''); + return ( + + { + ev.preventDefault(); + navigateToUrl(backUrl); + }} + href={backUrl} + iconType="arrowLeft" + > + {backOptions.text} + + + ); +}); +HeaderLinkBack.displayName = 'HeaderLinkBack'; + const HeaderPageComponent: React.FC = ({ backOptions, backComponent, @@ -99,62 +120,36 @@ const HeaderPageComponent: React.FC = ({ title, titleNode, ...rest -}) => { - const { navigateToUrl } = useKibana().services.application; +}) => ( + <> + + + {backOptions && } + {!backOptions && backComponent && <>{backComponent}} - const { formatUrl } = useFormatUrl(backOptions?.pageId ?? SecurityPageName.overview); - const backUrl = formatUrl(backOptions?.href ?? ''); - const goTo = useCallback( - (ev) => { - ev.preventDefault(); - if (backOptions) { - navigateToUrl(backUrl); - } - }, - [backOptions, navigateToUrl, backUrl] - ); - return ( - <> - - - {backOptions && ( - - - {backOptions.text} - - - )} - - {!backOptions && backComponent && <>{backComponent}} - {titleNode || ( - - )} + {titleNode || ( + <Title + draggableArguments={draggableArguments} + title={title} + badgeOptions={badgeOptions} + /> + )} - {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} - {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} - {border && isLoading && <EuiProgress size="xs" color="accent" />} - </HeaderSection> + {subtitle && <Subtitle data-test-subj="header-page-subtitle" items={subtitle} />} + {subtitle2 && <Subtitle data-test-subj="header-page-subtitle-2" items={subtitle2} />} + {border && isLoading && <EuiProgress size="xs" color="accent" />} + </HeaderSection> - {children && ( - <EuiPageHeaderSection data-test-subj="header-page-supplements"> - {children} - </EuiPageHeaderSection> - )} - {!hideSourcerer && <Sourcerer scope={SourcererScopeName.default} />} - </EuiPageHeader> - {/* Manually add a 'padding-bottom' to header */} - <EuiSpacer size="l" /> - </> - ); -}; + {children && ( + <EuiPageHeaderSection data-test-subj="header-page-supplements"> + {children} + </EuiPageHeaderSection> + )} + {!hideSourcerer && <Sourcerer scope={SourcererScopeName.default} />} + </EuiPageHeader> + {/* Manually add a 'padding-bottom' to header */} + <EuiSpacer size="l" /> + </> +); export const HeaderPage = React.memo(HeaderPageComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index b7defcc8c2af9..6681ee2cb7e8f 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -9,9 +9,9 @@ import { isEmpty } from 'lodash/fp'; import { useCallback } from 'react'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { navTabs } from '../../../app/home/home_navigations'; -import { APP_ID } from '../../../../common/constants'; -import { useKibana } from '../../lib/kibana'; -import { SiemNavTabKey } from '../navigation/types'; +import { useAppUrl } from '../../lib/kibana/hooks'; +import { SecurityNavKey } from '../navigation/types'; +import { SecurityPageName } from '../../../app/types'; export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; @@ -33,9 +33,11 @@ interface FormatUrlOptions { export type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string; -export const useFormatUrl = (page: SiemNavTabKey) => { - const { getUrlForApp } = useKibana().services.application; - const search = useGetUrlSearch(navTabs[page]); +export const useFormatUrl = (page: SecurityPageName) => { + const { getAppUrl } = useAppUrl(); + const tab = page in navTabs ? navTabs[page as SecurityNavKey] : undefined; + const search = useGetUrlSearch(tab); + const formatUrl = useCallback<FormatUrl>( (path: string, { absolute = false, skipSearch = false } = {}) => { const pathArr = path.split('?'); @@ -48,9 +50,9 @@ export const useFormatUrl = (page: SiemNavTabKey) => { ? '' : `?${pathArr[1]}` }`; - return getUrlForApp(APP_ID, { deepLinkId: page, path: formattedPath, absolute }); + return getAppUrl({ deepLinkId: page, path: formattedPath, absolute }); }, - [getUrlForApp, page, search] + [getAppUrl, page, search] ); return { formatUrl, search }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 1b1b3c9af4bfc..e147e8b7fc958 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -6,7 +6,7 @@ */ import { UrlStateType } from '../url_state/constants'; -import { SecurityPageName, SecurityPageGroupName } from '../../../app/types'; +import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; @@ -27,15 +27,14 @@ export interface NavGroupTab { id: string; name: string; } +export enum SecurityNavGroupKey { + detect = 'detect', + explore = 'explore', + investigate = 'investigate', + manage = 'manage', +} -export type SecurityNavTabGroupKey = - | SecurityPageGroupName.detect - | SecurityPageGroupName.explore - | SecurityPageGroupName.investigate - | SecurityPageGroupName.manage; - -export type NavTabGroups = Record<SecurityNavTabGroupKey, NavGroupTab>; - +export type SecurityNavGroup = Record<SecurityNavGroupKey, NavGroupTab>; export interface NavTab { id: string; name: string; @@ -44,8 +43,7 @@ export interface NavTab { urlKey?: UrlStateType; pageId?: SecurityPageName; } - -export type SiemNavTabKey = +export type SecurityNavKey = | SecurityPageName.overview | SecurityPageName.hosts | SecurityPageName.network @@ -59,7 +57,7 @@ export type SiemNavTabKey = | SecurityPageName.trustedApps | SecurityPageName.eventFilters; -export type SiemNavTab = Record<SiemNavTabKey, NavTab>; +export type SecurityNav = Record<SecurityNavKey, NavTab>; export type GetUrlForApp = ( appId: string, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx index 011378db1b31c..258dad531837a 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx @@ -12,9 +12,9 @@ import { makeMapStateToProps } from '../url_state/helpers'; import { getSearch } from './helpers'; import { SearchNavTab } from './types'; -export const useGetUrlSearch = (tab: SearchNavTab) => { +export const useGetUrlSearch = (tab?: SearchNavTab) => { const mapState = makeMapStateToProps(); const { urlState } = useDeepEqualSelector(mapState); - const urlSearch = useMemo(() => getSearch(tab, urlState), [tab, urlState]); + const urlSearch = useMemo(() => (tab ? getSearch(tab, urlState) : ''), [tab, urlState]); return urlSearch; }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 47ffb20c21d5a..e3549aa6ec047 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -7,7 +7,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { KibanaPageTemplateProps } from '../../../../../../../../src/plugins/kibana_react/public'; -import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { useKibana } from '../../../lib/kibana/kibana_react'; +import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { SecurityPageName } from '../../../../app/types'; import { useSecuritySolutionNavigation } from '.'; import { CONSTANTS } from '../../url_state/constants'; @@ -16,6 +17,7 @@ import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { UrlInputsModel } from '../../../store/inputs/model'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; +jest.mock('../../../lib/kibana/kibana_react'); jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_selector'); jest.mock('../../../utils/route/use_route_spy'); @@ -94,7 +96,7 @@ describe('useSecuritySolutionNavigation', () => { "icon": "logoSecurity", "items": Array [ Object { - "id": "securitySolution", + "id": "main", "items": Array [ Object { "data-href": "securitySolution/overview?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index e04ec7727a08f..fffe59fceff41 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -7,11 +7,11 @@ import React, { useCallback, useMemo } from 'react'; import { EuiSideNavItemType } from '@elastic/eui/src/components/side_nav/side_nav_types'; -import { navTabGroups } from '../../../../app/home/home_navigations'; -import { APP_ID } from '../../../../../common/constants'; +import { securityNavGroup } from '../../../../app/home/home_navigations'; import { getSearch } from '../helpers'; import { PrimaryNavigationItemsProps } from './types'; -import { useGetUserCasesPermissions, useKibana } from '../../../lib/kibana'; +import { useGetUserCasesPermissions } from '../../../lib/kibana'; +import { useNavigation } from '../../../lib/kibana/hooks'; import { NavTab } from '../types'; export const usePrimaryNavigationItems = ({ @@ -19,7 +19,7 @@ export const usePrimaryNavigationItems = ({ selectedTabId, ...urlStateProps }: PrimaryNavigationItemsProps): Array<EuiSideNavItemType<{}>> => { - const { navigateToApp, getUrlForApp } = useKibana().services.application; + const { navigateTo, getAppUrl } = useNavigation(); const getSideNav = useCallback( (tab: NavTab) => { @@ -29,10 +29,10 @@ export const usePrimaryNavigationItems = ({ const handleClick = (ev: React.MouseEvent) => { ev.preventDefault(); - navigateToApp(APP_ID, { deepLinkId: id, path: urlSearch }); + navigateTo({ deepLinkId: id, path: urlSearch }); }; - const appHref = getUrlForApp(APP_ID, { deepLinkId: id, path: urlSearch }); + const appHref = getAppUrl({ deepLinkId: id, path: urlSearch }); return { 'data-href': appHref, @@ -45,7 +45,7 @@ export const usePrimaryNavigationItems = ({ onClick: handleClick, }; }, - [getUrlForApp, navigateToApp, selectedTabId, urlStateProps] + [getAppUrl, navigateTo, selectedTabId, urlStateProps] ); const navItemsToDisplay = usePrimaryNavigationItemsToDisplay(navTabs); @@ -63,27 +63,30 @@ export const usePrimaryNavigationItems = ({ function usePrimaryNavigationItemsToDisplay(navTabs: Record<string, NavTab>) { const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - return [ - { - id: APP_ID, - name: '', - items: [navTabs.overview], - }, - { - ...navTabGroups.detect, - items: [navTabs.alerts, navTabs.rules, navTabs.exceptions], - }, - { - ...navTabGroups.explore, - items: [navTabs.hosts, navTabs.network], - }, - { - ...navTabGroups.investigate, - items: hasCasesReadPermissions ? [navTabs.timelines, navTabs.case] : [navTabs.timelines], - }, - { - ...navTabGroups.manage, - items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], - }, - ]; + return useMemo( + () => [ + { + id: 'main', + name: '', + items: [navTabs.overview], + }, + { + ...securityNavGroup.detect, + items: [navTabs.alerts, navTabs.rules, navTabs.exceptions], + }, + { + ...securityNavGroup.explore, + items: [navTabs.hosts, navTabs.network], + }, + { + ...securityNavGroup.investigate, + items: hasCasesReadPermissions ? [navTabs.timelines, navTabs.case] : [navTabs.timelines], + }, + { + ...securityNavGroup.manage, + items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], + }, + ], + [navTabs, hasCasesReadPermissions] + ); } diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index 702a532949428..ae2e509a7d94e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; import { Provider } from 'react-redux'; -import { useInitSourcerer } from '.'; +import { getScopeFromPath, useInitSourcerer } from '.'; import { mockPatterns } from './mocks'; // import { SourcererScopeName } from '../../store/sourcerer/model'; import { RouteSpyState } from '../../utils/route/types'; @@ -180,3 +180,18 @@ describe('Sourcerer Hooks', () => { }); }); }); + +describe('getScopeFromPath', () => { + it('should return default scope', async () => { + expect(getScopeFromPath('/')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/exceptions')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/rules')).toBe(SourcererScopeName.default); + expect(getScopeFromPath('/rules/create')).toBe(SourcererScopeName.default); + }); + + it('should return detections scope', async () => { + expect(getScopeFromPath('/alerts')).toBe(SourcererScopeName.detections); + expect(getScopeFromPath('/rules/id/foo')).toBe(SourcererScopeName.detections); + expect(getScopeFromPath('/rules/id/foo/edit')).toBe(SourcererScopeName.detections); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index 8cc075de324a2..002c40fc9d428 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -8,11 +8,13 @@ import { useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; +import { matchPath } from 'react-router-dom'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIndexFields } from '../source'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; +import { ALERTS_PATH, RULES_PATH } from '../../../../common/constants'; import { TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../hooks/use_selector'; @@ -125,3 +127,14 @@ export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName const SourcererScope = useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); return SourcererScope; }; + +export const getScopeFromPath = ( + pathname: string +): SourcererScopeName.default | SourcererScopeName.detections => { + return matchPath(pathname, { + path: [ALERTS_PATH, `${RULES_PATH}/id/:id`], + strict: false, + }) == null + ? SourcererScopeName.default + : SourcererScopeName.detections; +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 1b05c6a857263..b9bbf7afd3626 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -15,6 +15,7 @@ import { set } from '@elastic/safer-lodash-set'; import { APP_ID, DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; +import { NavigateToAppOptions } from '../../../../../../../src/core/public'; import { StartServices } from '../../../types'; import { useUiSetting, useKibana } from './kibana_react'; @@ -169,8 +170,15 @@ export const useAppUrl = () => { const { getUrlForApp } = useKibana().services.application; const getAppUrl = useCallback( - ({ appId = APP_ID, ...options }: { appId?: string; deepLinkId?: string; path?: string }) => - getUrlForApp(appId, options), + ({ + appId = APP_ID, + ...options + }: { + appId?: string; + deepLinkId?: string; + path?: string; + absolute?: boolean; + }) => getUrlForApp(appId, options), [getUrlForApp] ); return { getAppUrl }; @@ -191,9 +199,7 @@ export const useNavigateTo = () => { }: { url?: string; appId?: string; - deepLinkId?: string; - path?: string; - }) => { + } & NavigateToAppOptions) => { if (url) { navigateToUrl(url); } else { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx index 31d1ce6d41153..70101021bc4f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx @@ -27,8 +27,8 @@ jest.mock('react-router-dom', () => { }); const mockNavigateToApp = jest.fn(); -jest.mock('../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../common/lib/kibana/kibana_react'); return { ...original, @@ -43,6 +43,13 @@ jest.mock('../../../common/lib/kibana', () => { }, }, }), + }; +}); + +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + return { + ...original, useUiSetting$: jest.fn().mockReturnValue([]), useGetUserSavedObjectPermissions: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx index 880817af856f8..0595fd96d1377 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx @@ -27,7 +27,20 @@ jest.mock('react-router-dom', () => { }); jest.mock('../../../../common/components/link_to'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../../common/lib/kibana/kibana_react'); + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); jest.mock('../../../containers/detection_engine/rules/api', () => ({ getPrePackagedRulesStatus: jest.fn().mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx index 5688b4065ab76..90568e28793a3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.tsx @@ -16,8 +16,7 @@ import { SecurityPageName } from '../../../../app/types'; import { useFormatUrl } from '../../../../common/components/link_to'; import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; import { useUserData } from '../../user_info'; -import { APP_ID } from '../../../../../common/constants'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useNavigateTo } from '../../../../common/lib/kibana/hooks'; const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ @@ -40,14 +39,14 @@ const PrePackagedRulesPromptComponent: React.FC<PrePackagedRulesPromptProps> = ( createPrePackagedRules(); }, [createPrePackagedRules]); const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const { navigateToApp } = useKibana().services.application; + const { navigateTo } = useNavigateTo(); const goToCreateRule = useCallback( (ev) => { ev.preventDefault(); - navigateToApp(APP_ID, { deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); + navigateTo({ deepLinkId: SecurityPageName.rules, path: getCreateRuleUrl() }); }, - [navigateToApp] + [navigateTo] ); const [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 3d488f1f08c98..1f2bda768d19c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -305,7 +305,7 @@ const CreateRulePageComponent: React.FC = () => { <MaxWidthEuiFlexItem> <DetectionEngineHeaderPage backOptions={{ - href: getRulesUrl(), + path: getRulesUrl(), text: i18n.BACK_TO_RULES, pageId: SecurityPageName.rules, }} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index a7a9b31d1f408..66f62ad3ebeab 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -578,7 +578,7 @@ const RuleDetailsPageComponent = () => { <Display show={!globalFullScreen}> <DetectionEngineHeaderPage backOptions={{ - href: getRulesUrl(), + path: getRulesUrl(), text: i18n.BACK_TO_RULES, pageId: SecurityPageName.rules, dataTestSubj: 'ruleDetailsBackToAllRules', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 4786d7f2eae78..caec85f537d2b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -355,7 +355,7 @@ const EditRulePageComponent: FC = () => { <MaxWidthEuiFlexItem> <DetectionEngineHeaderPage backOptions={{ - href: getRuleDetailsUrl(ruleId ?? ''), + path: getRuleDetailsUrl(ruleId ?? ''), text: `${i18n.BACK_TO} ${rule?.name ?? ''}`, pageId: SecurityPageName.rules, dataTestSubj: 'ruleEditBackToRuleDetails', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 70b6d766fe786..df454b21ee725 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20811,7 +20811,6 @@ "xpack.securitySolution.rowRenderer.wasPreventedFromExecutingAMaliciousProcessDescription": "悪意のあるファイルの実行が防止されました", "xpack.securitySolution.rowRenderer.wasPreventedFromModifyingAMaliciousFileDescription": "悪意のあるファイルの修正が防止されました", "xpack.securitySolution.rowRenderer.wasPreventedFromRenamingAMaliciousFileDescription": "悪意のあるファイルの名前変更が防止されました", - "xpack.securitySolution.search.administration": "エンドポイント管理", "xpack.securitySolution.search.administration.trustedApps": "信頼できるアプリケーション", "xpack.securitySolution.search.cases": "ケース", "xpack.securitySolution.search.cases.configure": "ケースを構成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 02e17eaac3f57..447ba99945edc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -21143,7 +21143,6 @@ "xpack.securitySolution.rowRenderer.wasPreventedFromExecutingAMaliciousProcessDescription": "被阻止执行恶意进程", "xpack.securitySolution.rowRenderer.wasPreventedFromModifyingAMaliciousFileDescription": "被阻止修改恶意文件", "xpack.securitySolution.rowRenderer.wasPreventedFromRenamingAMaliciousFileDescription": "被阻止重命名恶意文件", - "xpack.securitySolution.search.administration": "终端管理", "xpack.securitySolution.search.administration.trustedApps": "受信任的应用程序", "xpack.securitySolution.search.cases": "案例", "xpack.securitySolution.search.cases.configure": "配置案例", From 8d84c2f2119d8a18f0cc08d48af10ca0cd9ac60d Mon Sep 17 00:00:00 2001 From: Ashokaditya <am.struktr@gmail.com> Date: Mon, 5 Jul 2021 14:19:41 +0200 Subject: [PATCH 04/48] [Security Solution][Endpoint] Mark code for refactor (#104278) * mark for refactor refs elastic/security-team/issues/1407 refs elastic/kibana/pull/101961/files * update comments review change --- .../pages/endpoint_hosts/store/middleware.test.ts | 2 +- .../pages/endpoint_hosts/store/middleware.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 6cf5e989fb645..a123f06f62f96 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -304,7 +304,7 @@ describe('endpoint list middleware', () => { }); }); - describe('handle Endpoint Pending Actions state actions', () => { + describe.skip('handle Endpoint Pending Actions state actions', () => { let mockedApis: ReturnType<typeof endpointPageHttpMock>; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 2f8ced9d2a771..e34e9cf5a83f3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -166,6 +166,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -286,6 +288,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -331,6 +335,8 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory<EndpointState }); } } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); } @@ -537,6 +543,8 @@ const endpointsTotal = async (http: HttpStart): Promise<number> => { }) ).total; } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(`error while trying to check for total endpoints`); logError(error); } @@ -547,6 +555,8 @@ const doEndpointsExist = async (http: HttpStart): Promise<boolean> => { try { return (await endpointsTotal(http)) > 0; } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(`error while trying to check if endpoints exist`); logError(error); } @@ -613,6 +623,8 @@ async function getEndpointPackageInfo( payload: createLoadedResourceState(packageInfo), }); } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use // Ignore Errors, since this should not hinder the user's ability to use the UI logError(error); dispatch({ @@ -663,6 +675,8 @@ const loadEndpointsPendingActions = async ({ payload: createLoadedResourceState(agentIdToPendingActions), }); } catch (error) { + // TODO should handle the error instead of logging it to the browser + // Also this is an anti-pattern we shouldn't use logError(error); } }; From 4c448c9d61115ee39c9454693319adc049d54cc8 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Mon, 5 Jul 2021 14:21:00 +0200 Subject: [PATCH 05/48] [Reporting] New UI for migrating reporting indices ILM policy (#103853) * revert revert * wip; first iteration of server-side functionality * wip; public side code started. using the new ilm policy status endpoint * * refactored ReportingIlmPolicyManager -> IlmReportingManager * restructured files for the policy manager code * re-added the IlmPolicyStatusResponse interface * added the ability to use useRequest * * removed extra server-side endpoint, we do both migration steps in one * added ilm policy context and context for api client * added es ui shared so we can use userequest * added working link to ILM policy * refactor of the migration route response (again), added logic for determining when to show ilm-policy link * fix type issues and refactor to use testbed pattern for report listing component * added tests for base cases of ilm policy banner * remove non-existent prop * added minor fixes (handling 404 from ES) and added API integration tests * resolve merge conflict and remove unused file * update toast error message Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/reporting/common/constants.ts | 6 + x-pack/plugins/reporting/common/types.ts | 6 + x-pack/plugins/reporting/kibana.json | 1 + .../public/lib/ilm_policy_status_context.tsx | 43 + .../lib/reporting_api_client/context.tsx | 38 + .../public/lib/reporting_api_client/hooks.ts | 18 + .../public/lib/reporting_api_client/index.ts | 12 + .../reporting_api_client.ts | 11 +- .../report_listing.test.tsx.snap | 10802 +++++++++++++++- .../public/management/ilm_policy_link.tsx | 47 + .../ilm_policy_migration_needed_callout.tsx | 94 + .../migrate_ilm_policy_callout/index.tsx | 37 + .../management/mount_management_section.tsx | 25 +- .../public/management/report_listing.test.tsx | 223 +- .../public/management/report_listing.tsx | 47 +- x-pack/plugins/reporting/public/plugin.ts | 10 +- .../reporting/public/shared_imports.ts | 20 + .../check_ilm_migration_status.ts | 41 + .../server/lib/deprecations/index.ts | 12 + .../server/lib/deprecations/types.ts | 14 + x-pack/plugins/reporting/server/lib/index.ts | 2 +- .../constants.ts} | 2 +- .../ilm_policy_manager/ilm_policy_manager.ts | 46 + .../lib/store/ilm_policy_manager/index.ts | 9 + .../reporting/server/lib/store/index.ts | 1 + .../reporting/server/lib/store/store.ts | 53 +- .../reporting/server/routes/deprecations.ts | 110 + .../plugins/reporting/server/routes/index.ts | 6 +- .../ilm_migration_apis.ts | 115 + .../reporting_without_security/index.ts | 1 + .../services/scenarios.ts | 33 + 31 files changed, 11129 insertions(+), 756 deletions(-) create mode 100644 x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx create mode 100644 x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx create mode 100644 x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts create mode 100644 x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts rename x-pack/plugins/reporting/public/lib/{ => reporting_api_client}/reporting_api_client.ts (93%) create mode 100644 x-pack/plugins/reporting/public/management/ilm_policy_link.tsx create mode 100644 x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx create mode 100644 x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx create mode 100644 x-pack/plugins/reporting/public/shared_imports.ts create mode 100644 x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts create mode 100644 x-pack/plugins/reporting/server/lib/deprecations/index.ts create mode 100644 x-pack/plugins/reporting/server/lib/deprecations/types.ts rename x-pack/plugins/reporting/server/lib/store/{report_ilm_policy.ts => ilm_policy_manager/constants.ts} (83%) create mode 100644 x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts create mode 100644 x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts create mode 100644 x-pack/plugins/reporting/server/routes/deprecations.ts create mode 100644 x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index f20c6c2d52fdd..c95c837c4959f 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -91,6 +91,12 @@ export const API_BASE_GENERATE = `${API_BASE_URL}/generate`; export const API_LIST_URL = `${API_BASE_URL}/jobs`; export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`; +export const API_GET_ILM_POLICY_STATUS = `${API_BASE_URL}/ilm_policy_status`; +export const API_CREATE_ILM_POLICY_URL = `${API_BASE_URL}/ilm_policy`; +export const API_MIGRATE_ILM_POLICY_URL = `${API_BASE_URL}/deprecations/migrate_ilm_policy`; + +export const ILM_POLICY_NAME = 'kibana-reporting'; + // hacky endpoint: download CSV without queueing a report export const API_BASE_URL_V1 = '/api/reporting/v1'; // export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv_searchsource`; diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 8205b4f13a320..6efaf42a5ad14 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -164,3 +164,9 @@ export type DownloadReportFn = (jobId: JobId) => DownloadLink; type ManagementLink = string; export type ManagementLinkFn = () => ManagementLink; + +export type IlmPolicyMigrationStatus = 'policy-not-found' | 'indices-not-managed-by-policy' | 'ok'; + +export interface IlmPolicyStatusResponse { + status: IlmPolicyMigrationStatus; +} diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index ddba61e9a0b8d..6a8f3a3e4e5ec 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -6,6 +6,7 @@ "configPath": ["xpack", "reporting"], "requiredPlugins": [ "data", + "esUiShared", "home", "management", "licensing", diff --git a/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx b/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx new file mode 100644 index 0000000000000..78b2e77d09aee --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/ilm_policy_status_context.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React, { createContext, useContext } from 'react'; + +import { IlmPolicyStatusResponse } from '../../common/types'; + +import { useCheckIlmPolicyStatus } from './reporting_api_client'; + +type UseCheckIlmPolicyStatus = ReturnType<typeof useCheckIlmPolicyStatus>; + +interface ContextValue { + status: undefined | IlmPolicyStatusResponse['status']; + isLoading: UseCheckIlmPolicyStatus['isLoading']; + recheckStatus: UseCheckIlmPolicyStatus['resendRequest']; +} + +const IlmPolicyStatusContext = createContext<undefined | ContextValue>(undefined); + +export const IlmPolicyStatusContextProvider: FunctionComponent = ({ children }) => { + const { isLoading, data, resendRequest: recheckStatus } = useCheckIlmPolicyStatus(); + + return ( + <IlmPolicyStatusContext.Provider value={{ isLoading, status: data?.status, recheckStatus }}> + {children} + </IlmPolicyStatusContext.Provider> + ); +}; + +export type UseIlmPolicyStatusReturn = ReturnType<typeof useIlmPolicyStatus>; + +export const useIlmPolicyStatus = (): ContextValue => { + const ctx = useContext(IlmPolicyStatusContext); + if (!ctx) { + throw new Error('"useIlmPolicyStatus" can only be used inside of "IlmPolicyStatusContext"'); + } + return ctx; +}; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx new file mode 100644 index 0000000000000..37857943774d4 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from 'src/core/public'; +import type { FunctionComponent } from 'react'; +import React, { createContext, useContext } from 'react'; + +import type { ReportingAPIClient } from './reporting_api_client'; + +interface ContextValue { + http: HttpSetup; + apiClient: ReportingAPIClient; +} + +const InternalApiClientContext = createContext<undefined | ContextValue>(undefined); + +export const InternalApiClientClientProvider: FunctionComponent<{ + http: HttpSetup; + apiClient: ReportingAPIClient; +}> = ({ http, apiClient, children }) => { + return ( + <InternalApiClientContext.Provider value={{ http, apiClient }}> + {children} + </InternalApiClientContext.Provider> + ); +}; + +export const useInternalApiClient = (): ContextValue => { + const ctx = useContext(InternalApiClientContext); + if (!ctx) { + throw new Error('"useInternalApiClient" can only be used inside of "InternalApiClientContext"'); + } + return ctx; +}; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts new file mode 100644 index 0000000000000..afd8222fd3831 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRequest, UseRequestResponse } from '../../shared_imports'; +import { IlmPolicyStatusResponse } from '../../../common/types'; + +import { API_GET_ILM_POLICY_STATUS } from '../../../common/constants'; + +import { useInternalApiClient } from './context'; + +export const useCheckIlmPolicyStatus = (): UseRequestResponse<IlmPolicyStatusResponse> => { + const { http } = useInternalApiClient(); + return useRequest(http, { path: API_GET_ILM_POLICY_STATUS, method: 'get' }); +}; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts new file mode 100644 index 0000000000000..b32d675a1d209 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './reporting_api_client'; + +export * from './hooks'; + +export { InternalApiClientClientProvider, useInternalApiClient } from './context'; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts similarity index 93% rename from x-pack/plugins/reporting/public/lib/reporting_api_client.ts rename to x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 4ce9e8760f21f..64caac0e27bdd 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -12,8 +12,9 @@ import { API_BASE_GENERATE, API_BASE_URL, API_LIST_URL, + API_MIGRATE_ILM_POLICY_URL, REPORTING_MANAGEMENT_HOME, -} from '../../common/constants'; +} from '../../../common/constants'; import { DownloadReportFn, JobId, @@ -21,8 +22,8 @@ import { ReportApiJSON, ReportDocument, ReportSource, -} from '../../common/types'; -import { add } from '../notifier/job_completion_notifications'; +} from '../../../common/types'; +import { add } from '../../notifier/job_completion_notifications'; export interface JobQueueEntry { _id: string; @@ -167,4 +168,8 @@ export class ReportingAPIClient { this.http.post(`${API_BASE_URL}/diagnose/screenshot`, { asSystemRequest: true, }); + + public migrateReportingIndicesIlmPolicy = (): Promise<void> => { + return this.http.put(`${API_MIGRATE_ILM_POLICY_URL}`); + }; } diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 93df3c8d99935..9ce249aa32a1d 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -1,82 +1,116 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ReportListing Report job listing with some items 1`] = ` -Array [ - <EuiBasicTable - columns={ - Array [ - Object { - "field": "object_title", - "name": "Report", - "render": [Function], - }, - Object { - "field": "created_at", - "name": "Created at", - "render": [Function], - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - }, - Object { - "actions": Array [ - Object { - "render": [Function], - }, - ], - "name": "Actions", - }, - ] - } - data-test-subj="reportJobListing" - isSelectable={true} - itemId="id" - items={Array []} - loading={true} - noItemsMessage="Loading reports" - onChange={[Function]} - pagination={ - Object { - "hidePerPageOptions": true, - "pageIndex": 0, - "pageSize": 10, - "totalItemCount": 0, - } - } - responsive={true} - selection={ - Object { - "itemId": "id", - "onSelectionChange": [Function], - } - } - tableCaption="Reports generated in Kibana applications" - tableLayout="fixed" - > - <div - className="euiBasicTable euiBasicTable-loading" - data-test-subj="reportJobListing" - > - <div> - <EuiTableHeaderMobile> +<div + className="euiBasicTable" + data-test-subj="reportJobListing" +> + <div> + <EuiTableHeaderMobile> + <div + className="euiTableHeaderMobile" + > + <EuiFlexGroup + alignItems="baseline" + justifyContent="spaceBetween" + responsive={false} + > <div - className="euiTableHeaderMobile" + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsBaseline euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" > - <EuiFlexGroup - alignItems="baseline" - justifyContent="spaceBetween" - responsive={false} + <EuiFlexItem + grow={false} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <EuiI18n + default="Select all rows" + token="euiBasicTable.selectAllRows" + > + <EuiCheckbox + aria-label="Select all rows" + checked={false} + compressed={false} + disabled={false} + id="_selection_column-checkbox_generated-id" + indeterminate={false} + label="Select all rows" + onChange={[Function]} + > + <div + className="euiCheckbox" + > + <input + aria-label="Select all rows" + checked={false} + className="euiCheckbox__input" + disabled={false} + id="_selection_column-checkbox_generated-id" + onChange={[Function]} + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + <label + className="euiCheckbox__label" + htmlFor="_selection_column-checkbox_generated-id" + > + Select all rows + </label> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </EuiFlexItem> + <EuiFlexItem + grow={false} > <div - className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsBaseline euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" + className="euiFlexItem euiFlexItem--flexGrowZero" + /> + </EuiFlexItem> + </div> + </EuiFlexGroup> + </div> + </EuiTableHeaderMobile> + <EuiTable + id="generated-id" + responsive={true} + tableLayout="fixed" + > + <table + className="euiTable euiTable--responsive" + id="generated-id" + tabIndex={-1} + > + <EuiScreenReaderOnly> + <caption + className="euiScreenReaderOnly euiTableCaption" + > + <EuiDelayRender + delay={500} + /> + </caption> + </EuiScreenReaderOnly> + <EuiTableHeader> + <thead> + <tr> + <EuiTableHeaderCellCheckbox + key="_selection_column_h" > - <EuiFlexItem - grow={false} + <th + className="euiTableHeaderCellCheckbox" + scope="col" + style={ + Object { + "width": undefined, + } + } > <div - className="euiFlexItem euiFlexItem--flexGrowZero" + className="euiTableCellContent" > <EuiI18n default="Select all rows" @@ -86,20 +120,23 @@ Array [ aria-label="Select all rows" checked={false} compressed={false} - disabled={true} + data-test-subj="checkboxSelectAll" + disabled={false} id="_selection_column-checkbox_generated-id" indeterminate={false} - label="Select all rows" + label={null} onChange={[Function]} + type="inList" > <div - className="euiCheckbox" + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" > <input aria-label="Select all rows" checked={false} className="euiCheckbox__input" - disabled={true} + data-test-subj="checkboxSelectAll" + disabled={false} id="_selection_column-checkbox_generated-id" onChange={[Function]} type="checkbox" @@ -107,466 +144,246 @@ Array [ <div className="euiCheckbox__square" /> - <label - className="euiCheckbox__label" - htmlFor="_selection_column-checkbox_generated-id" - > - Select all rows - </label> </div> </EuiCheckbox> </EuiI18n> </div> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - /> - </EuiFlexItem> - </div> - </EuiFlexGroup> - </div> - </EuiTableHeaderMobile> - <EuiTable - id="generated-id" - responsive={true} - tableLayout="fixed" - > - <table - className="euiTable euiTable--responsive" - id="generated-id" - tabIndex={-1} - > - <EuiScreenReaderOnly> - <caption - className="euiScreenReaderOnly euiTableCaption" + </th> + </EuiTableHeaderCellCheckbox> + <EuiTableHeaderCell + align="left" + data-test-subj="tableHeaderCell_object_title_0" + key="_data_h_object_title_0" > - <EuiDelayRender - delay={500} - /> - </caption> - </EuiScreenReaderOnly> - <EuiTableHeader> - <thead> - <tr> - <EuiTableHeaderCellCheckbox - key="_selection_column_h" - > - <th - className="euiTableHeaderCellCheckbox" - scope="col" - style={ - Object { - "width": undefined, - } - } + <th + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_object_title_0" + role="columnheader" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <CellContents + className="euiTableCellContent" + showSortMsg={false} + > + <span + className="euiTableCellContent" > - <div - className="euiTableCellContent" - > + <EuiInnerText> <EuiI18n - default="Select all rows" - token="euiBasicTable.selectAllRows" + default="{innerText}; {description}" + token="euiTableHeaderCell.titleTextWithDesc" + values={ + Object { + "description": undefined, + "innerText": "Report", + } + } > - <EuiCheckbox - aria-label="Select all rows" - checked={false} - compressed={false} - data-test-subj="checkboxSelectAll" - disabled={true} - id="_selection_column-checkbox_generated-id" - indeterminate={false} - label={null} - onChange={[Function]} - type="inList" + <span + className="euiTableCellContent__text" + title="Report" > - <div - className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" - > - <input - aria-label="Select all rows" - checked={false} - className="euiCheckbox__input" - data-test-subj="checkboxSelectAll" - disabled={true} - id="_selection_column-checkbox_generated-id" - onChange={[Function]} - type="checkbox" - /> - <div - className="euiCheckbox__square" - /> - </div> - </EuiCheckbox> + Report + </span> </EuiI18n> - </div> - </th> - </EuiTableHeaderCellCheckbox> - <EuiTableHeaderCell - align="left" - data-test-subj="tableHeaderCell_object_title_0" - key="_data_h_object_title_0" - > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_object_title_0" - role="columnheader" - scope="col" - style={ - Object { - "width": undefined, - } - } - > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Report", - } - } - > - <span - className="euiTableCellContent__text" - title="Report" - > - Report - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell - align="left" - data-test-subj="tableHeaderCell_created_at_1" - key="_data_h_created_at_1" - > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_created_at_1" - role="columnheader" - scope="col" - style={ - Object { - "width": undefined, - } - } + </EuiInnerText> + </span> + </CellContents> + </th> + </EuiTableHeaderCell> + <EuiTableHeaderCell + align="left" + data-test-subj="tableHeaderCell_created_at_1" + key="_data_h_created_at_1" + > + <th + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_created_at_1" + role="columnheader" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <CellContents + className="euiTableCellContent" + showSortMsg={false} + > + <span + className="euiTableCellContent" > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" + <EuiInnerText> + <EuiI18n + default="{innerText}; {description}" + token="euiTableHeaderCell.titleTextWithDesc" + values={ + Object { + "description": undefined, + "innerText": "Created at", + } + } > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Created at", - } - } - > - <span - className="euiTableCellContent__text" - title="Created at" - > - Created at - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell - align="left" - data-test-subj="tableHeaderCell_status_2" - key="_data_h_status_2" - > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_status_2" - role="columnheader" - scope="col" - style={ - Object { - "width": undefined, - } - } + <span + className="euiTableCellContent__text" + title="Created at" + > + Created at + </span> + </EuiI18n> + </EuiInnerText> + </span> + </CellContents> + </th> + </EuiTableHeaderCell> + <EuiTableHeaderCell + align="left" + data-test-subj="tableHeaderCell_status_2" + key="_data_h_status_2" + > + <th + className="euiTableHeaderCell" + data-test-subj="tableHeaderCell_status_2" + role="columnheader" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <CellContents + className="euiTableCellContent" + showSortMsg={false} + > + <span + className="euiTableCellContent" > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" + <EuiInnerText> + <EuiI18n + default="{innerText}; {description}" + token="euiTableHeaderCell.titleTextWithDesc" + values={ + Object { + "description": undefined, + "innerText": "Status", + } + } > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Status", - } - } - > - <span - className="euiTableCellContent__text" - title="Status" - > - Status - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell - align="right" - key="_actions_h_3" - > - <th - className="euiTableHeaderCell" - role="columnheader" - scope="col" - style={ - Object { - "width": undefined, - } - } + <span + className="euiTableCellContent__text" + title="Status" + > + Status + </span> + </EuiI18n> + </EuiInnerText> + </span> + </CellContents> + </th> + </EuiTableHeaderCell> + <EuiTableHeaderCell + align="right" + key="_actions_h_3" + > + <th + className="euiTableHeaderCell" + role="columnheader" + scope="col" + style={ + Object { + "width": undefined, + } + } + > + <CellContents + className="euiTableCellContent euiTableCellContent--alignRight" + showSortMsg={false} + > + <span + className="euiTableCellContent euiTableCellContent--alignRight" > - <CellContents - className="euiTableCellContent euiTableCellContent--alignRight" - showSortMsg={false} - > - <span - className="euiTableCellContent euiTableCellContent--alignRight" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Actions", - } - } - > - <span - className="euiTableCellContent__text" - title="Actions" - > - Actions - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - </tr> - </thead> - </EuiTableHeader> - <EuiTableBody> - <tbody> - <EuiTableRow> - <tr - className="euiTableRow" - > - <EuiTableRowCell - align="center" - colSpan={5} - isMobileFullWidth={true} - > - <td - className="euiTableRowCell euiTableRowCell--isMobileFullWidth" - colSpan={5} - style={ - Object { - "width": undefined, + <EuiInnerText> + <EuiI18n + default="{innerText}; {description}" + token="euiTableHeaderCell.titleTextWithDesc" + values={ + Object { + "description": undefined, + "innerText": "Actions", + } } - } - > - <div - className="euiTableCellContent euiTableCellContent--alignCenter" > <span className="euiTableCellContent__text" + title="Actions" > - Loading reports + Actions </span> - </div> - </td> - </EuiTableRowCell> - </tr> - </EuiTableRow> - </tbody> - </EuiTableBody> - </table> - </EuiTable> - </div> - </div> - </EuiBasicTable>, - <div - className="euiBasicTable euiBasicTable-loading" - data-test-subj="reportJobListing" - > - <div> - <EuiTableHeaderMobile> - <div - className="euiTableHeaderMobile" + </EuiI18n> + </EuiInnerText> + </span> + </CellContents> + </th> + </EuiTableHeaderCell> + </tr> + </thead> + </EuiTableHeader> + <EuiTableBody + bodyRef={[Function]} > - <EuiFlexGroup - alignItems="baseline" - justifyContent="spaceBetween" - responsive={false} - > - <div - className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsBaseline euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" + <tbody> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} > - <EuiFlexItem - grow={false} - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <EuiI18n - default="Select all rows" - token="euiBasicTable.selectAllRows" - > - <EuiCheckbox - aria-label="Select all rows" - checked={false} - compressed={false} - disabled={true} - id="_selection_column-checkbox_generated-id" - indeterminate={false} - label="Select all rows" - onChange={[Function]} - > - <div - className="euiCheckbox" - > - <input - aria-label="Select all rows" - checked={false} - className="euiCheckbox__input" - disabled={true} - id="_selection_column-checkbox_generated-id" - onChange={[Function]} - type="checkbox" - /> - <div - className="euiCheckbox__square" - /> - <label - className="euiCheckbox__label" - htmlFor="_selection_column-checkbox_generated-id" - > - Select all rows - </label> - </div> - </EuiCheckbox> - </EuiI18n> - </div> - </EuiFlexItem> - <EuiFlexItem - grow={false} + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - /> - </EuiFlexItem> - </div> - </EuiFlexGroup> - </div> - </EuiTableHeaderMobile> - <EuiTable - id="generated-id" - responsive={true} - tableLayout="fixed" - > - <table - className="euiTable euiTable--responsive" - id="generated-id" - tabIndex={-1} - > - <EuiScreenReaderOnly> - <caption - className="euiScreenReaderOnly euiTableCaption" - > - <EuiDelayRender - delay={500} - /> - </caption> - </EuiScreenReaderOnly> - <EuiTableHeader> - <thead> - <tr> - <EuiTableHeaderCellCheckbox - key="_selection_column_h" + <EuiTableRowCellCheckbox + key="_selection_column_k90e51pk1ieucbae0c3t8wo2" > - <th - className="euiTableHeaderCellCheckbox" - scope="col" - style={ - Object { - "width": undefined, - } - } + <td + className="euiTableRowCellCheckbox" > <div className="euiTableCellContent" > <EuiI18n - default="Select all rows" - token="euiBasicTable.selectAllRows" + default="Select this row" + token="euiBasicTable.selectThisRow" > <EuiCheckbox - aria-label="Select all rows" + aria-label="Select this row" checked={false} compressed={false} - data-test-subj="checkboxSelectAll" - disabled={true} - id="_selection_column-checkbox_generated-id" + data-test-subj="checkboxSelectRow-k90e51pk1ieucbae0c3t8wo2" + disabled={false} + id="_selection_column_k90e51pk1ieucbae0c3t8wo2-checkbox" indeterminate={false} - label={null} onChange={[Function]} + title="Select this row" type="inList" > <div className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" > <input - aria-label="Select all rows" + aria-label="Select this row" checked={false} className="euiCheckbox__input" - data-test-subj="checkboxSelectAll" - disabled={true} - id="_selection_column-checkbox_generated-id" + data-test-subj="checkboxSelectRow-k90e51pk1ieucbae0c3t8wo2" + disabled={false} + id="_selection_column_k90e51pk1ieucbae0c3t8wo2-checkbox" onChange={[Function]} + title="Select this row" type="checkbox" /> <div @@ -576,231 +393,9864 @@ Array [ </EuiCheckbox> </EuiI18n> </div> - </th> - </EuiTableHeaderCellCheckbox> - <EuiTableHeaderCell + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell align="left" - data-test-subj="tableHeaderCell_object_title_0" - key="_data_h_object_title_0" + key="_data_column_object_title_k90e51pk1ieucbae0c3t8wo2_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_object_title_0" - role="columnheader" - scope="col" + <td + className="euiTableRowCell" style={ Object { "width": undefined, } } > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Report", - } - } + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" > - <span - className="euiTableCellContent__text" - title="Report" + <EuiTextColor + color="subdued" > - Report - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell align="left" - data-test-subj="tableHeaderCell_created_at_1" - key="_data_h_created_at_1" + key="_data_column_created_at_k90e51pk1ieucbae0c3t8wo2_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_created_at_1" - role="columnheader" - scope="col" + <td + className="euiTableRowCell" style={ Object { "width": undefined, } } > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Created at", - } - } - > - <span - className="euiTableCellContent__text" - title="Created at" - > - Created at - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 05:01 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell align="left" - data-test-subj="tableHeaderCell_status_2" - key="_data_h_status_2" - > - <th - className="euiTableHeaderCell" - data-test-subj="tableHeaderCell_status_2" - role="columnheader" - scope="col" - style={ - Object { - "width": undefined, - } + key="_data_column_status_k90e51pk1ieucbae0c3t8wo2_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, } - > - <CellContents - className="euiTableCellContent" - showSortMsg={false} - > - <span - className="euiTableCellContent" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Status", - } - } - > - <span - className="euiTableCellContent__text" - title="Status" - > - Status - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> - <EuiTableHeaderCell - align="right" - key="_actions_h_3" + } + setScopeRow={false} + textOnly={false} > - <th - className="euiTableHeaderCell" - role="columnheader" - scope="col" + <td + className="euiTableRowCell" style={ Object { "width": undefined, } } > - <CellContents - className="euiTableCellContent euiTableCellContent--alignRight" - showSortMsg={false} - > - <span - className="euiTableCellContent euiTableCellContent--alignRight" - > - <EuiInnerText> - <EuiI18n - default="{innerText}; {description}" - token="euiTableHeaderCell.titleTextWithDesc" - values={ - Object { - "description": undefined, - "innerText": "Actions", - } + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="Pending - waiting for job to be processed" + id="xpack.reporting.listing.tableValue.statusDetail.pendingStatusReachedText" + values={Object {}} + > + Pending - waiting for job to be processed + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k90e51pk1ieucbae0c3t8wo2_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 0, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo2", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": undefined, + "status": "pending", + "statusLabel": "Pending", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k90e51pk1ieucbae0c3t8wo2" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 0, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo2", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": undefined, + "status": "pending", + "statusLabel": "Pending", + "type": "printable_pdf", + "warnings": undefined, } + } + key="item_action_k90e51pk1ieucbae0c3t8wo2_0" + > + <div + className="euiTableCellContent__hoverItem" > - <span - className="euiTableCellContent__text" - title="Actions" + <div + onBlur={[Function]} + onFocus={[Function]} > - Actions - </span> - </EuiI18n> - </EuiInnerText> - </span> - </CellContents> - </th> - </EuiTableHeaderCell> + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 0, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo2", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": undefined, + "status": "pending", + "statusLabel": "Pending", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 0, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo2", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": undefined, + "status": "pending", + "statusLabel": "Pending", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 0, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo2", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": undefined, + "status": "pending", + "statusLabel": "Pending", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k90e51pk1ieucbae0c3t8wo2" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> </tr> - </thead> - </EuiTableHeader> - <EuiTableBody> - <tbody> - <EuiTableRow> - <tr - className="euiTableRow" - > - <EuiTableRowCell - align="center" - colSpan={5} - isMobileFullWidth={true} - > - <td - className="euiTableRowCell euiTableRowCell--isMobileFullWidth" - colSpan={5} - style={ - Object { - "width": undefined, - } + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k90e51pk1ieucbae0c3t8wo1" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k90e51pk1ieucbae0c3t8wo1" + disabled={false} + id="_selection_column_k90e51pk1ieucbae0c3t8wo1-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k90e51pk1ieucbae0c3t8wo1" + disabled={false} + id="_selection_column_k90e51pk1ieucbae0c3t8wo1-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k90e51pk1ieucbae0c3t8wo1_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" > <div - className="euiTableCellContent euiTableCellContent--alignCenter" + className="" + key=".0" > - <span - className="euiTableCellContent__text" + <div> + My Canvas Workpad + </div> + <EuiText + size="s" > - Loading reports + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k90e51pk1ieucbae0c3t8wo1_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 05:01 PM + </div> + <span> + elastic </span> </div> - </td> - </EuiTableRowCell> - </tr> - </EuiTableRow> - </tbody> - </EuiTableBody> - </table> - </EuiTable> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k90e51pk1ieucbae0c3t8wo1_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Processing (attempt 1 of 1)", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 05:01 PM + </span>, + } + } + > + Processing (attempt 1 of 1) at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 05:01 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k90e51pk1ieucbae0c3t8wo1_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo1", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T21:01:14.526Z", + "status": "processing", + "statusLabel": "Processing", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k90e51pk1ieucbae0c3t8wo1" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo1", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T21:01:14.526Z", + "status": "processing", + "statusLabel": "Processing", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k90e51pk1ieucbae0c3t8wo1_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo1", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T21:01:14.526Z", + "status": "processing", + "statusLabel": "Processing", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo1", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T21:01:14.526Z", + "status": "processing", + "statusLabel": "Processing", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": undefined, + "created_at": "2020-04-14T21:01:13.064Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90e51pk1ieucbae0c3t8wo1", + "max_attempts": 1, + "max_size_reached": false, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T21:01:14.526Z", + "status": "processing", + "statusLabel": "Processing", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k90e51pk1ieucbae0c3t8wo1" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k90cmthd1gv8cbae0c2le8bo" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k90cmthd1gv8cbae0c2le8bo" + disabled={false} + id="_selection_column_k90cmthd1gv8cbae0c2le8bo-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k90cmthd1gv8cbae0c2le8bo" + disabled={false} + id="_selection_column_k90cmthd1gv8cbae0c2le8bo-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k90cmthd1gv8cbae0c2le8bo_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k90cmthd1gv8cbae0c2le8bo_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 04:19 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k90cmthd1gv8cbae0c2le8bo_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 04:19 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 04:19 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k90cmthd1gv8cbae0c2le8bo_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T20:19:14.748Z", + "created_at": "2020-04-14T20:19:02.977Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90cmthd1gv8cbae0c2le8bo", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T20:19:04.073Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k90cmthd1gv8cbae0c2le8bo" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T20:19:14.748Z", + "created_at": "2020-04-14T20:19:02.977Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90cmthd1gv8cbae0c2le8bo", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T20:19:04.073Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k90cmthd1gv8cbae0c2le8bo_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T20:19:14.748Z", + "created_at": "2020-04-14T20:19:02.977Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90cmthd1gv8cbae0c2le8bo", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T20:19:04.073Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T20:19:14.748Z", + "created_at": "2020-04-14T20:19:02.977Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90cmthd1gv8cbae0c2le8bo", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T20:19:04.073Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T20:19:14.748Z", + "created_at": "2020-04-14T20:19:02.977Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k90cmthd1gv8cbae0c2le8bo", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T20:19:04.073Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k90cmthd1gv8cbae0c2le8bo" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k906958e1d4wcbae0c9hip1a" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k906958e1d4wcbae0c9hip1a" + disabled={false} + id="_selection_column_k906958e1d4wcbae0c9hip1a-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k906958e1d4wcbae0c9hip1a" + disabled={false} + id="_selection_column_k906958e1d4wcbae0c9hip1a-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k906958e1d4wcbae0c9hip1a_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k906958e1d4wcbae0c9hip1a_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 01:20 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k906958e1d4wcbae0c9hip1a_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed with warnings", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:21 PM + </span>, + } + } + > + Completed with warnings at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:21 PM + </span> + </FormattedMessage> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + <FormattedMessage + defaultMessage="Errors occurred: see job info for details." + id="xpack.reporting.listing.tableValue.statusDetail.warningsText" + values={Object {}} + > + Errors occurred: see job info for details. + </FormattedMessage> + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k906958e1d4wcbae0c9hip1a_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:21:08.223Z", + "created_at": "2020-04-14T17:20:27.326Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k906958e1d4wcbae0c9hip1a", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:20:29.444Z", + "status": "completed_with_warnings", + "statusLabel": "Completed with warnings", + "type": "printable_pdf", + "warnings": Array [ + "An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. TimeoutError: waiting for selector \\"[data-shared-item],[data-shared-items-count]\\" failed: timeout 30000ms exceeded", + ], + } + } + itemId="k906958e1d4wcbae0c9hip1a" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:21:08.223Z", + "created_at": "2020-04-14T17:20:27.326Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k906958e1d4wcbae0c9hip1a", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:20:29.444Z", + "status": "completed_with_warnings", + "statusLabel": "Completed with warnings", + "type": "printable_pdf", + "warnings": Array [ + "An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. TimeoutError: waiting for selector \\"[data-shared-item],[data-shared-items-count]\\" failed: timeout 30000ms exceeded", + ], + } + } + key="item_action_k906958e1d4wcbae0c9hip1a_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:21:08.223Z", + "created_at": "2020-04-14T17:20:27.326Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k906958e1d4wcbae0c9hip1a", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:20:29.444Z", + "status": "completed_with_warnings", + "statusLabel": "Completed with warnings", + "type": "printable_pdf", + "warnings": Array [ + "An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. TimeoutError: waiting for selector \\"[data-shared-item],[data-shared-items-count]\\" failed: timeout 30000ms exceeded", + ], + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:21:08.223Z", + "created_at": "2020-04-14T17:20:27.326Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k906958e1d4wcbae0c9hip1a", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:20:29.444Z", + "status": "completed_with_warnings", + "statusLabel": "Completed with warnings", + "type": "printable_pdf", + "warnings": Array [ + "An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. TimeoutError: waiting for selector \\"[data-shared-item],[data-shared-items-count]\\" failed: timeout 30000ms exceeded", + ], + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:21:08.223Z", + "created_at": "2020-04-14T17:20:27.326Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k906958e1d4wcbae0c9hip1a", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:20:29.444Z", + "status": "completed_with_warnings", + "statusLabel": "Completed with warnings", + "type": "printable_pdf", + "warnings": Array [ + "An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. TimeoutError: waiting for selector \\"[data-shared-item],[data-shared-items-count]\\" failed: timeout 30000ms exceeded", + ], + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k906958e1d4wcbae0c9hip1a" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k9067y2a1d4wcbae0cad38n0" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k9067y2a1d4wcbae0cad38n0" + disabled={false} + id="_selection_column_k9067y2a1d4wcbae0cad38n0-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k9067y2a1d4wcbae0cad38n0" + disabled={false} + id="_selection_column_k9067y2a1d4wcbae0cad38n0-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k9067y2a1d4wcbae0cad38n0_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k9067y2a1d4wcbae0cad38n0_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 01:19 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k9067y2a1d4wcbae0cad38n0_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:19 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:19 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k9067y2a1d4wcbae0cad38n0_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:53.244Z", + "created_at": "2020-04-14T17:19:31.379Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067y2a1d4wcbae0cad38n0", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:39.883Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k9067y2a1d4wcbae0cad38n0" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:53.244Z", + "created_at": "2020-04-14T17:19:31.379Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067y2a1d4wcbae0cad38n0", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:39.883Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k9067y2a1d4wcbae0cad38n0_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:53.244Z", + "created_at": "2020-04-14T17:19:31.379Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067y2a1d4wcbae0cad38n0", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:39.883Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:53.244Z", + "created_at": "2020-04-14T17:19:31.379Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067y2a1d4wcbae0cad38n0", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:39.883Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:53.244Z", + "created_at": "2020-04-14T17:19:31.379Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067y2a1d4wcbae0cad38n0", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:39.883Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k9067y2a1d4wcbae0cad38n0" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k9067s1m1d4wcbae0cdnvcms" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k9067s1m1d4wcbae0cdnvcms" + disabled={false} + id="_selection_column_k9067s1m1d4wcbae0cdnvcms-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k9067s1m1d4wcbae0cdnvcms" + disabled={false} + id="_selection_column_k9067s1m1d4wcbae0cdnvcms-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k9067s1m1d4wcbae0cdnvcms_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k9067s1m1d4wcbae0cdnvcms_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 01:19 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k9067s1m1d4wcbae0cdnvcms_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:19 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:19 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k9067s1m1d4wcbae0cdnvcms_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:36.822Z", + "created_at": "2020-04-14T17:19:23.578Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067s1m1d4wcbae0cdnvcms", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:25.247Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k9067s1m1d4wcbae0cdnvcms" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:36.822Z", + "created_at": "2020-04-14T17:19:23.578Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067s1m1d4wcbae0cdnvcms", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:25.247Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k9067s1m1d4wcbae0cdnvcms_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:36.822Z", + "created_at": "2020-04-14T17:19:23.578Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067s1m1d4wcbae0cdnvcms", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:25.247Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:36.822Z", + "created_at": "2020-04-14T17:19:23.578Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067s1m1d4wcbae0cdnvcms", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:25.247Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:19:36.822Z", + "created_at": "2020-04-14T17:19:23.578Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9067s1m1d4wcbae0cdnvcms", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:19:25.247Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k9067s1m1d4wcbae0cdnvcms" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k9065q3s1d4wcbae0c00fxlh" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k9065q3s1d4wcbae0c00fxlh" + disabled={false} + id="_selection_column_k9065q3s1d4wcbae0c00fxlh-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k9065q3s1d4wcbae0c00fxlh" + disabled={false} + id="_selection_column_k9065q3s1d4wcbae0c00fxlh-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k9065q3s1d4wcbae0c00fxlh_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k9065q3s1d4wcbae0c00fxlh_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 01:17 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k9065q3s1d4wcbae0c00fxlh_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:18 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:18 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k9065q3s1d4wcbae0c00fxlh_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:18:03.910Z", + "created_at": "2020-04-14T17:17:47.752Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9065q3s1d4wcbae0c00fxlh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:17:50.379Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k9065q3s1d4wcbae0c00fxlh" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:18:03.910Z", + "created_at": "2020-04-14T17:17:47.752Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9065q3s1d4wcbae0c00fxlh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:17:50.379Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k9065q3s1d4wcbae0c00fxlh_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:18:03.910Z", + "created_at": "2020-04-14T17:17:47.752Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9065q3s1d4wcbae0c00fxlh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:17:50.379Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:18:03.910Z", + "created_at": "2020-04-14T17:17:47.752Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9065q3s1d4wcbae0c00fxlh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:17:50.379Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:18:03.910Z", + "created_at": "2020-04-14T17:17:47.752Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k9065q3s1d4wcbae0c00fxlh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:17:50.379Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k9065q3s1d4wcbae0c00fxlh" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k905zdw11d34cbae0c3y6tzh" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k905zdw11d34cbae0c3y6tzh" + disabled={false} + id="_selection_column_k905zdw11d34cbae0c3y6tzh-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k905zdw11d34cbae0c3y6tzh" + disabled={false} + id="_selection_column_k905zdw11d34cbae0c3y6tzh-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k905zdw11d34cbae0c3y6tzh_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + My Canvas Workpad + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + canvas workpad + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k905zdw11d34cbae0c3y6tzh_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-14 @ 01:12 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k905zdw11d34cbae0c3y6tzh_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:13 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-14 @ 01:13 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k905zdw11d34cbae0c3y6tzh_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:13:03.719Z", + "created_at": "2020-04-14T17:12:51.985Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k905zdw11d34cbae0c3y6tzh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:12:52.431Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + itemId="k905zdw11d34cbae0c3y6tzh" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:13:03.719Z", + "created_at": "2020-04-14T17:12:51.985Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k905zdw11d34cbae0c3y6tzh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:12:52.431Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + key="item_action_k905zdw11d34cbae0c3y6tzh_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:13:03.719Z", + "created_at": "2020-04-14T17:12:51.985Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k905zdw11d34cbae0c3y6tzh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:12:52.431Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:13:03.719Z", + "created_at": "2020-04-14T17:12:51.985Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k905zdw11d34cbae0c3y6tzh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:12:52.431Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-14T17:13:03.719Z", + "created_at": "2020-04-14T17:12:51.985Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k905zdw11d34cbae0c3y6tzh", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "My Canvas Workpad", + "object_type": "canvas workpad", + "started_at": "2020-04-14T17:12:52.431Z", + "status": "completed", + "statusLabel": "Completed", + "type": "printable_pdf", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k905zdw11d34cbae0c3y6tzh" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + <EuiTableRow + hasActions={true} + isSelectable={true} + isSelected={false} + > + <tr + className="euiTableRow euiTableRow-isSelectable euiTableRow-hasActions" + > + <EuiTableRowCellCheckbox + key="_selection_column_k8t4ylcb07mi9d006214ifyg" + > + <td + className="euiTableRowCellCheckbox" + > + <div + className="euiTableCellContent" + > + <EuiI18n + default="Select this row" + token="euiBasicTable.selectThisRow" + > + <EuiCheckbox + aria-label="Select this row" + checked={false} + compressed={false} + data-test-subj="checkboxSelectRow-k8t4ylcb07mi9d006214ifyg" + disabled={false} + id="_selection_column_k8t4ylcb07mi9d006214ifyg-checkbox" + indeterminate={false} + onChange={[Function]} + title="Select this row" + type="inList" + > + <div + className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel" + > + <input + aria-label="Select this row" + checked={false} + className="euiCheckbox__input" + data-test-subj="checkboxSelectRow-k8t4ylcb07mi9d006214ifyg" + disabled={false} + id="_selection_column_k8t4ylcb07mi9d006214ifyg-checkbox" + onChange={[Function]} + title="Select this row" + type="checkbox" + /> + <div + className="euiCheckbox__square" + /> + </div> + </EuiCheckbox> + </EuiI18n> + </div> + </td> + </EuiTableRowCellCheckbox> + <EuiTableRowCell + align="left" + key="_data_column_object_title_k8t4ylcb07mi9d006214ifyg_0" + mobileOptions={ + Object { + "header": "Report", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Report + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + count + </div> + <EuiText + size="s" + > + <div + className="euiText euiText--small" + > + <EuiTextColor + color="subdued" + > + <span + className="euiTextColor euiTextColor--subdued" + > + visualization + </span> + </EuiTextColor> + </div> + </EuiText> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_created_at_k8t4ylcb07mi9d006214ifyg_1" + mobileOptions={ + Object { + "header": "Created at", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Created at + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <div> + 2020-04-09 @ 03:09 PM + </div> + <span> + elastic + </span> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="left" + key="_data_column_status_k8t4ylcb07mi9d006214ifyg_2" + mobileOptions={ + Object { + "header": "Status", + "render": undefined, + } + } + setScopeRow={false} + textOnly={false} + > + <td + className="euiTableRowCell" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" + > + Status + </div> + <div + className="euiTableCellContent euiTableCellContent--overflowingContent" + > + <div + className="" + key=".0" + > + <FormattedMessage + defaultMessage="{statusLabel} at {statusTimestamp}" + id="xpack.reporting.listing.tableValue.statusDetail.statusTimestampText" + values={ + Object { + "statusLabel": "Completed", + "statusTimestamp": <span + className="eui-textNoWrap" + > + 2020-04-09 @ 03:10 PM + </span>, + } + } + > + Completed at + <span + className="eui-textNoWrap" + > + 2020-04-09 @ 03:10 PM + </span> + </FormattedMessage> + </div> + </div> + </td> + </EuiTableRowCell> + <EuiTableRowCell + align="right" + hasActions={true} + key="record_actions_k8t4ylcb07mi9d006214ifyg_3" + showOnHover={true} + textOnly={false} + > + <td + className="euiTableRowCell euiTableRowCell--hasActions" + style={ + Object { + "width": undefined, + } + } + > + <div + className="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--showOnHover euiTableCellContent--overflowingContent" + > + <ExpandedItemActions + actionEnabled={[Function]} + actions={ + Array [ + Object { + "render": [Function], + }, + ] + } + className="euiTableCellContent__hoverItem" + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-09T19:10:10.049Z", + "created_at": "2020-04-09T19:09:52.139Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k8t4ylcb07mi9d006214ifyg", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "count", + "object_type": "visualization", + "started_at": "2020-04-09T19:09:54.570Z", + "status": "completed", + "statusLabel": "Completed", + "type": "PNG", + "warnings": undefined, + } + } + itemId="k8t4ylcb07mi9d006214ifyg" + key=".0" + > + <CustomItemAction + action={ + Object { + "render": [Function], + } + } + className="euiTableCellContent__hoverItem" + enabled={true} + index={0} + item={ + Object { + "attempts": 1, + "completed_at": "2020-04-09T19:10:10.049Z", + "created_at": "2020-04-09T19:09:52.139Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k8t4ylcb07mi9d006214ifyg", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "count", + "object_type": "visualization", + "started_at": "2020-04-09T19:09:54.570Z", + "status": "completed", + "statusLabel": "Completed", + "type": "PNG", + "warnings": undefined, + } + } + key="item_action_k8t4ylcb07mi9d006214ifyg_0" + > + <div + className="euiTableCellContent__hoverItem" + > + <div + onBlur={[Function]} + onFocus={[Function]} + > + <ReportDownloadButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-09T19:10:10.049Z", + "created_at": "2020-04-09T19:09:52.139Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k8t4ylcb07mi9d006214ifyg", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "count", + "object_type": "visualization", + "started_at": "2020-04-09T19:09:54.570Z", + "status": "completed", + "statusLabel": "Completed", + "type": "PNG", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiToolTip + content="Download report" + delay="regular" + position="top" + > + <span + className="euiToolTipAnchor" + onKeyUp={[Function]} + onMouseOut={[Function]} + onMouseOver={[Function]} + > + <EuiButtonIcon + aria-label="Download report" + iconType="importAction" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + <button + aria-label="Download report" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + disabled={false} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="importAction" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="importAction" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </span> + </EuiToolTip> + </ReportDownloadButton> + <InjectIntl(ReportErrorButtonUi) + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-09T19:10:10.049Z", + "created_at": "2020-04-09T19:09:52.139Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k8t4ylcb07mi9d006214ifyg", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "count", + "object_type": "visualization", + "started_at": "2020-04-09T19:09:54.570Z", + "status": "completed", + "statusLabel": "Completed", + "type": "PNG", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <ReportErrorButtonUi + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + record={ + Object { + "attempts": 1, + "completed_at": "2020-04-09T19:10:10.049Z", + "created_at": "2020-04-09T19:09:52.139Z", + "created_by": "elastic", + "csv_contains_formulas": undefined, + "id": "k8t4ylcb07mi9d006214ifyg", + "max_attempts": 1, + "max_size_reached": undefined, + "object_title": "count", + "object_type": "visualization", + "started_at": "2020-04-09T19:09:54.570Z", + "status": "completed", + "statusLabel": "Completed", + "type": "PNG", + "warnings": undefined, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + /> + </InjectIntl(ReportErrorButtonUi)> + <ReportInfoButton + apiClient={ + Object { + "list": [Function], + "migrateReportingIndicesIlmPolicy": [MockFunction], + "total": [Function], + } + } + ilmPolicyContextValue={ + Object { + "isLoading": false, + "recheckStatus": [Function], + "status": undefined, + } + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + jobId="k8t4ylcb07mi9d006214ifyg" + license$={ + Object { + "subscribe": [Function], + } + } + navigateToUrl={[MockFunction]} + pollConfig={ + Object { + "jobCompletionNotifier": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 3, + }, + } + } + redirect={[MockFunction]} + toasts={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + urlService={ + Object { + "locators": Object { + "get": [Function], + }, + } + } + > + <EuiButtonIcon + aria-label="Show report info" + color="primary" + data-test-subj="reportInfoButton" + iconType="iInCircle" + onClick={[Function]} + > + <button + aria-label="Show report info" + className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="reportInfoButton" + disabled={false} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="iInCircle" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="iInCircle" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </ReportInfoButton> + </div> + </div> + </CustomItemAction> + </ExpandedItemActions> + </div> + </td> + </EuiTableRowCell> + </tr> + </EuiTableRow> + </tbody> + </EuiTableBody> + </table> + </EuiTable> + </div> + <PaginationBar + aria-controls="generated-id" + aria-label={ + <EuiI18n + default="Pagination for preceding table: {tableCaption}" + token="euiBasicTable.tablePagination" + values={ + Object { + "tableCaption": "Reports generated in Kibana applications", + } + } + /> + } + onPageChange={[Function]} + onPageSizeChange={[Function]} + pagination={ + Object { + "hidePerPageOptions": true, + "pageIndex": 0, + "pageSize": 10, + "totalItemCount": 18, + } + } + > + <div> + <EuiSpacer + size="m" + > + <div + className="euiSpacer euiSpacer--m" + /> + </EuiSpacer> + <EuiTablePagination + activePage={0} + aria-controls="generated-id" + hidePerPageOptions={true} + itemsPerPage={10} + itemsPerPageOptions={ + Array [ + 10, + 25, + 50, + ] + } + onChangeItemsPerPage={[Function]} + onChangePage={[Function]} + pageCount={2} + > + <EuiFlexGroup + alignItems="center" + justifyContent="spaceBetween" + responsive={false} + > + <div + className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow" + > + <EuiFlexItem + grow={false} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <div + className="euiFlexItem euiFlexItem--flexGrowZero" + > + <EuiPagination + activePage={0} + aria-controls="generated-id" + onPageClick={[Function]} + pageCount={2} + > + <nav + className="euiPagination" + > + <EuiI18n + default="Previous page, {page}" + token="euiPagination.previousPage" + values={ + Object { + "page": 0, + } + } + > + <EuiI18n + default="Previous page" + token="euiPagination.disabledPreviousPage" + > + <EuiButtonIcon + aria-label="Previous page" + color="text" + data-test-subj="pagination-button-previous" + disabled={true} + iconType="arrowLeft" + onClick={[Function]} + > + <button + aria-label="Previous page" + className="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="pagination-button-previous" + disabled={true} + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="arrowLeft" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="arrowLeft" + size="m" + /> + </EuiIcon> + </button> + </EuiButtonIcon> + </EuiI18n> + </EuiI18n> + <ul + className="euiPagination__list" + > + <PaginationButton + key="0" + pageIndex={0} + > + <li + className="euiPagination__item" + > + <EuiPaginationButton + aria-controls="generated-id" + hideOnMobile={true} + isActive={true} + onClick={[Function]} + pageIndex={0} + totalPages={2} + > + <EuiI18n + default="Page {page} of {totalPages}" + token="euiPaginationButton.longPageString" + values={ + Object { + "page": 1, + "totalPages": 2, + } + } + > + <EuiI18n + default="Page {page}" + token="euiPaginationButton.shortPageString" + values={ + Object { + "page": 1, + } + } + > + <EuiButtonEmpty + aria-controls="generated-id" + aria-current={true} + aria-label="Page 1 of 2" + className="euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" + color="text" + data-test-subj="pagination-button-0" + href="#generated-id" + isDisabled={true} + onClick={[Function]} + size="s" + > + <button + aria-controls="generated-id" + aria-current={true} + aria-label="Page 1 of 2" + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--small euiButtonEmpty-isDisabled euiPaginationButton euiPaginationButton-isActive euiPaginationButton--hideOnMobile" + data-test-subj="pagination-button-0" + disabled={true} + onClick={[Function]} + type="button" + > + <EuiButtonContent + className="euiButtonEmpty__content" + iconSide="left" + iconSize="m" + textProps={ + Object { + "className": "euiButtonEmpty__text", + } + } + > + <span + className="euiButtonContent euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + 1 + </span> + </span> + </EuiButtonContent> + </button> + </EuiButtonEmpty> + </EuiI18n> + </EuiI18n> + </EuiPaginationButton> + </li> + </PaginationButton> + <PaginationButton + key="1" + pageIndex={1} + > + <li + className="euiPagination__item" + > + <EuiPaginationButton + aria-controls="generated-id" + hideOnMobile={true} + isActive={false} + onClick={[Function]} + pageIndex={1} + totalPages={2} + > + <EuiI18n + default="Page {page} of {totalPages}" + token="euiPaginationButton.longPageString" + values={ + Object { + "page": 2, + "totalPages": 2, + } + } + > + <EuiI18n + default="Page {page}" + token="euiPaginationButton.shortPageString" + values={ + Object { + "page": 2, + } + } + > + <EuiButtonEmpty + aria-controls="generated-id" + aria-label="Page 2 of 2" + className="euiPaginationButton euiPaginationButton--hideOnMobile" + color="text" + data-test-subj="pagination-button-1" + href="#generated-id" + isDisabled={false} + onClick={[Function]} + size="s" + > + <a + aria-controls="generated-id" + aria-label="Page 2 of 2" + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--small euiPaginationButton euiPaginationButton--hideOnMobile" + data-test-subj="pagination-button-1" + href="#generated-id" + onClick={[Function]} + rel="noreferrer" + > + <EuiButtonContent + className="euiButtonEmpty__content" + iconSide="left" + iconSize="m" + textProps={ + Object { + "className": "euiButtonEmpty__text", + } + } + > + <span + className="euiButtonContent euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + 2 + </span> + </span> + </EuiButtonContent> + </a> + </EuiButtonEmpty> + </EuiI18n> + </EuiI18n> + </EuiPaginationButton> + </li> + </PaginationButton> + </ul> + <EuiI18n + default="Next page, {page}" + token="euiPagination.nextPage" + values={ + Object { + "page": 2, + } + } + > + <EuiI18n + default="Next page" + token="euiPagination.disabledNextPage" + > + <EuiButtonIcon + aria-controls="generated-id" + aria-label="Next page, 2" + color="text" + data-test-subj="pagination-button-next" + href="#generated-id" + iconType="arrowRight" + onClick={[Function]} + > + <a + aria-controls="generated-id" + aria-label="Next page, 2" + className="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall" + data-test-subj="pagination-button-next" + href="#generated-id" + onClick={[Function]} + rel="noreferrer" + > + <EuiIcon + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + size="m" + type="arrowRight" + > + <span + aria-hidden="true" + className="euiButtonIcon__icon" + color="inherit" + data-euiicon-type="arrowRight" + size="m" + /> + </EuiIcon> + </a> + </EuiButtonIcon> + </EuiI18n> + </EuiI18n> + </nav> + </EuiPagination> + </div> + </EuiFlexItem> + </div> + </EuiFlexGroup> + </EuiTablePagination> </div> - </div>, -] + </PaginationBar> +</div> `; diff --git a/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx b/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx new file mode 100644 index 0000000000000..3945ec5be9fa7 --- /dev/null +++ b/x-pack/plugins/reporting/public/management/ilm_policy_link.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import type { ApplicationStart } from 'src/core/public'; + +import { ILM_POLICY_NAME } from '../../common/constants'; +import { LocatorPublic, SerializableState } from '../shared_imports'; + +interface Props { + navigateToUrl: ApplicationStart['navigateToUrl']; + locator: LocatorPublic<SerializableState>; +} + +const i18nTexts = { + buttonLabel: i18n.translate('xpack.reporting.listing.reports.ilmPolicyLinkText', { + defaultMessage: 'Edit ILM policy', + }), +}; + +export const IlmPolicyLink: FunctionComponent<Props> = ({ locator, navigateToUrl }) => { + return ( + <EuiButtonEmpty + data-test-subj="ilmPolicyLink" + size="xs" + onClick={() => { + locator + .getUrl({ + page: 'policy_edit', + policyName: ILM_POLICY_NAME, + }) + .then((url) => { + navigateToUrl(url); + }); + }} + > + {i18nTexts.buttonLabel} + </EuiButtonEmpty> + ); +}; diff --git a/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx new file mode 100644 index 0000000000000..5bb3ac524e130 --- /dev/null +++ b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/ilm_policy_migration_needed_callout.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import { EuiCallOut, EuiButton, EuiCode } from '@elastic/eui'; + +import type { NotificationsSetup } from 'src/core/public'; + +import { ILM_POLICY_NAME } from '../../../common/constants'; + +import { useInternalApiClient } from '../../lib/reporting_api_client'; + +const i18nTexts = { + title: i18n.translate('xpack.reporting.listing.ilmPolicyCallout.migrationNeededTitle', { + defaultMessage: 'Migrate reporting indices', + }), + description: ( + <FormattedMessage + id="xpack.reporting.listing.ilmPolicyCallout.migrationNeededDescription" + defaultMessage="Reporting indices are not managed by the same ILM policy. This can lead to unexpected results for the lifecycle of reports stored in Elasticsearch indices. Reporting indices should all be managed by the {ilmPolicyName} policy." + values={{ + ilmPolicyName: <EuiCode>{ILM_POLICY_NAME}</EuiCode>, + }} + /> + ), + buttonLabel: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesButtonLabel', + { + defaultMessage: 'Migrate indices', + } + ), + migrateErrorTitle: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesErrorTitle', + { + defaultMessage: 'Could not migrate reporting indices', + } + ), + migrateSuccessTitle: i18n.translate( + 'xpack.reporting.listing.ilmPolicyCallout.migrateIndicesSuccessTitle', + { + defaultMessage: 'Successfully migrated reporting indices', + } + ), +}; + +interface Props { + toasts: NotificationsSetup['toasts']; + onMigrationDone: () => void; +} + +export const IlmPolicyMigrationNeededCallOut: FunctionComponent<Props> = ({ + toasts, + onMigrationDone, +}) => { + const [isMigratingIndices, setIsMigratingIndices] = useState(false); + + const { apiClient } = useInternalApiClient(); + + const migrateReportingIndices = async () => { + try { + setIsMigratingIndices(true); + await apiClient.migrateReportingIndicesIlmPolicy(); + onMigrationDone(); + toasts.addSuccess({ title: i18nTexts.migrateSuccessTitle }); + } catch (e) { + toasts.addError(e, { + title: i18nTexts.migrateErrorTitle, + toastMessage: e.body?.message, + }); + } finally { + setIsMigratingIndices(false); + } + }; + + return ( + <EuiCallOut data-test-subj="migrateReportingIndicesPolicyCallOut" title={i18nTexts.title}> + <p>{i18nTexts.description}</p> + <EuiButton + data-test-subj="migrateReportingIndicesButton" + isLoading={isMigratingIndices} + onClick={migrateReportingIndices} + > + {i18nTexts.buttonLabel} + </EuiButton> + </EuiCallOut> + ); +}; diff --git a/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.tsx new file mode 100644 index 0000000000000..892cbcdde5ede --- /dev/null +++ b/x-pack/plugins/reporting/public/management/migrate_ilm_policy_callout/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { EuiSpacer, EuiFlexItem } from '@elastic/eui'; + +import { NotificationsSetup } from 'src/core/public'; + +import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; + +import { IlmPolicyMigrationNeededCallOut } from './ilm_policy_migration_needed_callout'; + +interface Props { + toasts: NotificationsSetup['toasts']; +} + +export const MigrateIlmPolicyCallOut: FunctionComponent<Props> = ({ toasts }) => { + const { isLoading, recheckStatus, status } = useIlmPolicyStatus(); + + if (isLoading || !status || status === 'ok') { + return null; + } + + return ( + <> + <EuiSpacer size="m" /> + <EuiFlexItem> + <IlmPolicyMigrationNeededCallOut toasts={toasts} onMigrationDone={recheckStatus} /> + </EuiFlexItem> + </> + ); +}; diff --git a/x-pack/plugins/reporting/public/management/mount_management_section.tsx b/x-pack/plugins/reporting/public/management/mount_management_section.tsx index eb1057a9bdfc7..8d147628c6662 100644 --- a/x-pack/plugins/reporting/public/management/mount_management_section.tsx +++ b/x-pack/plugins/reporting/public/management/mount_management_section.tsx @@ -10,10 +10,11 @@ import * as React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Observable } from 'rxjs'; import { CoreSetup, CoreStart } from 'src/core/public'; -import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; import { ILicense } from '../../../licensing/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { ClientConfigType } from '../plugin'; +import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports'; import { ReportListing } from './report_listing'; export async function mountManagementSection( @@ -22,17 +23,23 @@ export async function mountManagementSection( license$: Observable<ILicense>, pollConfig: ClientConfigType['poll'], apiClient: ReportingAPIClient, + urlService: SharePluginSetup['url'], params: ManagementAppMountParams ) { render( <I18nProvider> - <ReportListing - toasts={coreSetup.notifications.toasts} - license$={license$} - pollConfig={pollConfig} - redirect={coreStart.application.navigateToApp} - apiClient={apiClient} - /> + <InternalApiClientClientProvider http={coreSetup.http} apiClient={apiClient}> + <IlmPolicyStatusContextProvider> + <ReportListing + toasts={coreSetup.notifications.toasts} + license$={license$} + pollConfig={pollConfig} + redirect={coreStart.application.navigateToApp} + navigateToUrl={coreStart.application.navigateToUrl} + urlService={urlService} + /> + </IlmPolicyStatusContextProvider> + </InternalApiClientClientProvider> </I18nProvider>, params.element ); diff --git a/x-pack/plugins/reporting/public/management/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx index efc1a7dfe3b20..0b278cbaa0449 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.test.tsx @@ -7,9 +7,22 @@ import React from 'react'; import { Observable } from 'rxjs'; -import { mountWithIntl } from '@kbn/test/jest'; -import { ILicense } from '../../../licensing/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { UnwrapPromise } from '@kbn/utility-types'; + +import { act } from 'react-dom/test-utils'; + +import { registerTestBed } from '@kbn/test/jest'; + +import type { SharePluginSetup, LocatorPublic } from '../../../../../src/plugins/share/public'; +import type { NotificationsSetup } from '../../../../../src/core/public'; +import { httpServiceMock, notificationServiceMock } from '../../../../../src/core/public/mocks'; + +import type { ILicense } from '../../../licensing/public'; + +import { IlmPolicyMigrationStatus } from '../../common/types'; + +import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -17,7 +30,7 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { }; }); -import { ReportListing } from './report_listing'; +import { ReportListing, Props } from './report_listing'; const reportingAPIClient = { list: () => @@ -33,6 +46,7 @@ const reportingAPIClient = { { _id: 'k8t4ylcb07mi9d006214ifyg', _index: '.reporting-2020.04.05', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-09T19:10:10.049Z', created_at: '2020-04-09T19:09:52.139Z', created_by: 'elastic', jobtype: 'PNG', kibana_id: 'f2e59b4e-f79b-4a48-8a7d-6d50a3c1d914', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'png', objectType: 'visualization', }, output: { content_type: 'image/png', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-09T19:09:52.137Z', layout: { dimensions: { height: 1575, width: 1423, }, id: 'png', }, objectType: 'visualization', relativeUrl: "/s/hsyjklk/app/visualize#/edit/94d1fe40-7a94-11ea-b373-0749f92ad295?_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!((enabled:!t,id:'1',params:(),schema:metric,type:count)),params:(addLegend:!f,addTooltip:!t,metric:(colorSchema:'Green%20to%20Red',colorsRange:!((from:0,to:10000)),invertColors:!f,labels:(show:!t),metricColorMode:None,percentageMode:!f,style:(bgColor:!f,bgFill:%23000,fontSize:60,labelColor:!f,subText:''),useRanges:!f),type:metric),title:count,type:metric))&_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15y,to:now))&indexPattern=d81752b0-7434-11ea-be36-1f978cda44d4&type=metric", title: 'count', }, priority: 10, process_expiration: '2020-04-09T19:14:54.570Z', started_at: '2020-04-09T19:09:54.570Z', status: 'completed', timeout: 300000, }, sort: [1586459392139], }, ]), // prettier-ignore total: () => Promise.resolve(18), + migrateReportingIndicesIlmPolicy: jest.fn(), } as any; const validCheck = { @@ -48,10 +62,6 @@ const license$ = { }, } as Observable<ILicense>; -const toasts = { - addDanger: jest.fn(), -} as any; - const mockPollConfig = { jobCompletionNotifier: { interval: 5000, @@ -64,22 +74,87 @@ const mockPollConfig = { }; describe('ReportListing', () => { - it('Report job listing with some items', () => { - const wrapper = mountWithIntl( - <ReportListing + let httpService: ReturnType<typeof httpServiceMock.createSetupContract>; + let ilmLocator: undefined | LocatorPublic<any>; + let urlService: SharePluginSetup['url']; + let testBed: UnwrapPromise<ReturnType<typeof setup>>; + let toasts: NotificationsSetup['toasts']; + + const createTestBed = registerTestBed( + (props?: Partial<Props>) => ( + <InternalApiClientClientProvider apiClient={reportingAPIClient as ReportingAPIClient} - license$={license$} - pollConfig={mockPollConfig} - redirect={jest.fn()} - toasts={toasts} - /> - ); - wrapper.update(); - const input = wrapper.find('[data-test-subj="reportJobListing"]'); - expect(input).toMatchSnapshot(); + http={httpService} + > + <IlmPolicyStatusContextProvider> + <ReportListing + license$={license$} + pollConfig={mockPollConfig} + redirect={jest.fn()} + navigateToUrl={jest.fn()} + urlService={urlService} + toasts={toasts} + {...props} + /> + </IlmPolicyStatusContextProvider> + </InternalApiClientClientProvider> + ), + { memoryRouter: { wrapComponent: false } } + ); + + const setup = async (props?: Partial<Props>) => { + const tb = await createTestBed(props); + const { find, exists, component } = tb; + + return { + ...tb, + actions: { + findListTable: () => find('reportJobListing'), + hasIlmMigrationBanner: () => exists('migrateReportingIndicesPolicyCallOut'), + hasIlmPolicyLink: () => exists('ilmPolicyLink'), + migrateIndices: async () => { + await act(async () => { + find('migrateReportingIndicesButton').simulate('click'); + }); + component.update(); + }, + }, + }; + }; + + const runSetup = async (props?: Partial<Props>) => { + await act(async () => { + testBed = await setup(props); + }); + testBed.component.update(); + }; + + beforeEach(async () => { + toasts = notificationServiceMock.createSetupContract().toasts; + httpService = httpServiceMock.createSetupContract(); + ilmLocator = ({ + getUrl: jest.fn(), + } as unknown) as LocatorPublic<any>; + + urlService = ({ + locators: { + get: () => ilmLocator, + }, + } as unknown) as SharePluginSetup['url']; + await runSetup(); + }); + + afterEach(() => { + jest.clearAllMocks(); }); - it('subscribes to license changes, and unsubscribes on dismount', () => { + it('Report job listing with some items', () => { + const { actions } = testBed; + const table = actions.findListTable(); + expect(table).toMatchSnapshot(); + }); + + it('subscribes to license changes, and unsubscribes on dismount', async () => { const unsubscribeMock = jest.fn(); const subMock = { subscribe: jest.fn().mockReturnValue({ @@ -87,19 +162,103 @@ describe('ReportListing', () => { }), } as any; - const wrapper = mountWithIntl( - <ReportListing - apiClient={reportingAPIClient as ReportingAPIClient} - license$={subMock as Observable<ILicense>} - pollConfig={mockPollConfig} - redirect={jest.fn()} - toasts={toasts} - /> - ); - wrapper.update(); + await runSetup({ license$: subMock }); + expect(subMock.subscribe).toHaveBeenCalled(); expect(unsubscribeMock).not.toHaveBeenCalled(); - wrapper.unmount(); + testBed.component.unmount(); expect(unsubscribeMock).toHaveBeenCalled(); }); + + describe('ILM policy', () => { + beforeEach(async () => { + httpService = httpServiceMock.createSetupContract(); + ilmLocator = ({ + getUrl: jest.fn(), + } as unknown) as LocatorPublic<any>; + + urlService = ({ + locators: { + get: () => ilmLocator, + }, + } as unknown) as SharePluginSetup['url']; + + await runSetup(); + }); + + it('shows the migrate banner when migration status is not "OK"', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmMigrationBanner()).toBe(true); + }); + + it('does not show the migrate banner when migration status is "OK"', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmMigrationBanner()).toBe(false); + }); + + it('hides the ILM policy link if there is no ILM policy', async () => { + const status: IlmPolicyMigrationStatus = 'policy-not-found'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(false); + }); + + it('hides the ILM policy link if there is no ILM policy locator', async () => { + ilmLocator = undefined; + const status: IlmPolicyMigrationStatus = 'ok'; // should never happen, but need to test that when the locator is missing we don't render the link + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(false); + }); + + it('always shows the ILM policy link if there is an ILM policy', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + await runSetup(); + const { actions } = testBed; + expect(actions.hasIlmPolicyLink()).toBe(true); + + const status2: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValue({ status: status2 }); + await runSetup(); + expect(actions.hasIlmPolicyLink()).toBe(true); + }); + + it('hides the banner after migrating indices', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + const status2: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValueOnce({ status }); + httpService.get.mockResolvedValueOnce({ status: status2 }); + await runSetup(); + const { actions } = testBed; + + expect(actions.hasIlmMigrationBanner()).toBe(true); + await actions.migrateIndices(); + expect(actions.hasIlmMigrationBanner()).toBe(false); + expect(actions.hasIlmPolicyLink()).toBe(true); + expect(toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + + it('informs users when migrations failed', async () => { + const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; + httpService.get.mockResolvedValueOnce({ status }); + reportingAPIClient.migrateReportingIndicesIlmPolicy.mockRejectedValueOnce(new Error('oops!')); + await runSetup(); + const { actions } = testBed; + + expect(actions.hasIlmMigrationBanner()).toBe(true); + await actions.migrateIndices(); + expect(toasts.addError).toHaveBeenCalledTimes(1); + expect(actions.hasIlmMigrationBanner()).toBe(true); + expect(actions.hasIlmPolicyLink()).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 0b6ece4d8bd02..749e42de526d3 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -13,6 +13,7 @@ import { EuiSpacer, EuiText, EuiTextColor, + EuiLoadingSpinner, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; @@ -26,10 +27,18 @@ import { JOB_STATUSES as JobStatuses } from '../../common/constants'; import { Poller } from '../../common/poller'; import { durationToNumber } from '../../common/schema_utils'; import { checkLicense } from '../lib/license_check'; -import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; +import { + JobQueueEntry, + ReportingAPIClient, + useInternalApiClient, +} from '../lib/reporting_api_client'; +import { useIlmPolicyStatus, UseIlmPolicyStatusReturn } from '../lib/ilm_policy_status_context'; +import type { SharePluginSetup } from '../shared_imports'; import { ClientConfigType } from '../plugin'; import { ReportDeleteButton, ReportDownloadButton, ReportErrorButton, ReportInfoButton } from './'; import { ReportDiagnostic } from './report_diagnostic'; +import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; +import { IlmPolicyLink } from './ilm_policy_link'; export interface Job { id: string; @@ -55,7 +64,10 @@ export interface Props { license$: LicensingPluginSetup['license$']; pollConfig: ClientConfigType['poll']; redirect: ApplicationStart['navigateToApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; toasts: ToastsSetup; + urlService: SharePluginSetup['url']; + ilmPolicyContextValue: UseIlmPolicyStatusReturn; } interface State { @@ -132,6 +144,10 @@ class ReportListingUi extends Component<Props, State> { } public render() { + const { ilmPolicyContextValue, urlService, navigateToUrl } = this.props; + const ilmLocator = urlService.locators.get('ILM_LOCATOR_ID'); + const hasIlmPolicy = ilmPolicyContextValue.status !== 'policy-not-found'; + const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); return ( <> <EuiPageHeader @@ -147,11 +163,22 @@ class ReportListingUi extends Component<Props, State> { } /> + <MigrateIlmPolicyCallOut toasts={this.props.toasts} /> + <EuiSpacer size={'l'} /> {this.renderTable()} <EuiSpacer size="s" /> - <EuiFlexGroup justifyContent="spaceBetween" direction="rowReverse"> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + {ilmPolicyContextValue.isLoading ? ( + <EuiLoadingSpinner /> + ) : ( + showIlmPolicyLink && ( + <IlmPolicyLink navigateToUrl={navigateToUrl} locator={ilmLocator!} /> + ) + )} + </EuiFlexItem> <EuiFlexItem grow={false}> <ReportDiagnostic apiClient={this.props.apiClient} /> </EuiFlexItem> @@ -531,4 +558,18 @@ class ReportListingUi extends Component<Props, State> { } } -export const ReportListing = injectI18n(ReportListingUi); +const PrivateReportListing = injectI18n(ReportListingUi); + +export const ReportListing = ( + props: Omit<Props, 'ilmPolicyContextValue' | 'intl' | 'apiClient'> +) => { + const ilmPolicyStatusValue = useIlmPolicyStatus(); + const { apiClient } = useInternalApiClient(); + return ( + <PrivateReportListing + {...props} + apiClient={apiClient} + ilmPolicyContextValue={ilmPolicyStatusValue} + /> + ); +}; diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index a2881af902072..fcbc4662c6e59 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -15,7 +15,6 @@ import { Plugin, PluginInitializerContext, } from 'src/core/public'; -import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../src/plugins/embeddable/public'; import { FeatureCatalogueCategory, @@ -23,7 +22,6 @@ import { HomePublicPluginStart, } from '../../../../src/plugins/home/public'; import { ManagementSetup, ManagementStart } from '../../../../src/plugins/management/public'; -import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; import { constants, getDefaultLayoutSelectors } from '../common'; import { durationToNumber } from '../common/schema_utils'; @@ -37,6 +35,13 @@ import { getSharedComponents } from './shared'; import { ReportingCsvShareProvider } from './share_context_menu/register_csv_reporting'; import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; +import type { + SharePluginSetup, + SharePluginStart, + UiActionsSetup, + UiActionsStart, +} from './shared_imports'; + export interface ClientConfigType { poll: { jobsRefresh: { interval: number; intervalErrorMultiplier: number } }; roles: { enabled: boolean }; @@ -159,6 +164,7 @@ export class ReportingPublicPlugin license$, this.config.poll, apiClient, + share.url, params ); }, diff --git a/x-pack/plugins/reporting/public/shared_imports.ts b/x-pack/plugins/reporting/public/shared_imports.ts new file mode 100644 index 0000000000000..010da46c07401 --- /dev/null +++ b/x-pack/plugins/reporting/public/shared_imports.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + SharePluginSetup, + SharePluginStart, + LocatorPublic, +} from '../../../../src/plugins/share/public'; + +export { useRequest, UseRequestResponse } from '../../../../src/plugins/es_ui_shared/public'; + +export type { SerializableState } from 'src/plugins/kibana_utils/common'; + +export type { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; + +export type { ManagementAppMountParams } from 'src/plugins/management/public'; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts b/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts new file mode 100644 index 0000000000000..dc20f92f38c94 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/check_ilm_migration_status.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + IndicesIndexStatePrefixedSettings, + IndicesIndexSettings, +} from '@elastic/elasticsearch/api/types'; +import { ILM_POLICY_NAME } from '../../../common/constants'; +import { IlmPolicyMigrationStatus } from '../../../common/types'; +import { IlmPolicyManager } from '../../lib/store/ilm_policy_manager'; +import type { DeprecationsDependencies } from './types'; + +export const checkIlmMigrationStatus = async ({ + reportingCore, + elasticsearchClient, +}: DeprecationsDependencies): Promise<IlmPolicyMigrationStatus> => { + const ilmPolicyManager = IlmPolicyManager.create({ client: elasticsearchClient }); + if (!(await ilmPolicyManager.doesIlmPolicyExist())) { + return 'policy-not-found'; + } + + const store = await reportingCore.getStore(); + const indexPattern = store.getReportingIndexPattern(); + + const { body: reportingIndicesSettings } = await elasticsearchClient.indices.getSettings({ + index: indexPattern, + }); + + const hasUnmanagedIndices = Object.values(reportingIndicesSettings).some((settings) => { + return ( + (settings?.settings as IndicesIndexStatePrefixedSettings)?.index?.lifecycle?.name !== + ILM_POLICY_NAME && + (settings?.settings as IndicesIndexSettings)?.['index.lifecycle']?.name !== ILM_POLICY_NAME + ); + }); + + return hasUnmanagedIndices ? 'indices-not-managed-by-policy' : 'ok'; +}; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/index.ts b/x-pack/plugins/reporting/server/lib/deprecations/index.ts new file mode 100644 index 0000000000000..95594940e07e2 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { checkIlmMigrationStatus } from './check_ilm_migration_status'; + +export const deprecations = { + checkIlmMigrationStatus, +}; diff --git a/x-pack/plugins/reporting/server/lib/deprecations/types.ts b/x-pack/plugins/reporting/server/lib/deprecations/types.ts new file mode 100644 index 0000000000000..c6e9e3b7ad920 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/deprecations/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from 'src/core/server'; +import type { ReportingCore } from '../../core'; + +export interface DeprecationsDependencies { + reportingCore: ReportingCore; + elasticsearchClient: ElasticsearchClient; +} diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index e66f72f88f8ea..b2a2a1edcd6a5 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -10,5 +10,5 @@ export { cryptoFactory } from './crypto'; export { ExportTypesRegistry, getExportTypesRegistry } from './export_types_registry'; export { LevelLogger } from './level_logger'; export { statuses } from './statuses'; -export { ReportingStore } from './store'; +export { ReportingStore, IlmPolicyManager } from './store'; export { startTrace } from './trace'; diff --git a/x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts similarity index 83% rename from x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts rename to x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts index 90636e3c523a3..bea2ba21c0846 100644 --- a/x-pack/plugins/reporting/server/lib/store/report_ilm_policy.ts +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IlmPutLifecycleRequest } from '@elastic/elasticsearch/api/types'; +import type { IlmPutLifecycleRequest } from '@elastic/elasticsearch/api/types'; export const reportingIlmPolicy: IlmPutLifecycleRequest['body'] = { policy: { diff --git a/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts new file mode 100644 index 0000000000000..ca0a74cae8726 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/ilm_policy_manager.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from 'src/core/server'; +import { ILM_POLICY_NAME } from '../../../../common/constants'; + +import { reportingIlmPolicy } from './constants'; + +/** + * Responsible for detecting and provisioning the reporting ILM policy. + * + * Uses the provided {@link ElasticsearchClient} to scope request privileges. + */ +export class IlmPolicyManager { + constructor(private readonly client: ElasticsearchClient) {} + + public static create(opts: { client: ElasticsearchClient }) { + return new IlmPolicyManager(opts.client); + } + + public async doesIlmPolicyExist(): Promise<boolean> { + try { + await this.client.ilm.getLifecycle({ policy: ILM_POLICY_NAME }); + return true; + } catch (e) { + if (e.statusCode === 404) { + return false; + } + throw e; + } + } + + /** + * Create the Reporting ILM policy + */ + public async createIlmPolicy(): Promise<void> { + await this.client.ilm.putLifecycle({ + policy: ILM_POLICY_NAME, + body: reportingIlmPolicy, + }); + } +} diff --git a/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts new file mode 100644 index 0000000000000..045a9ecb59997 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/ilm_policy_manager/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { reportingIlmPolicy } from './constants'; +export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts index 6b979325921a6..888918abbc344 100644 --- a/x-pack/plugins/reporting/server/lib/store/index.ts +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -8,3 +8,4 @@ export { ReportDocument } from '../../../common/types'; export { Report } from './report'; export { ReportingStore } from './store'; +export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 17c067a255b38..7a7dd20e1b25c 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -10,12 +10,15 @@ import { ElasticsearchClient } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; import { JobStatus } from '../../../common/types'; + +import { ILM_POLICY_NAME } from '../../../common/constants'; + import { ReportTaskParams } from '../tasks'; + +import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; -import { MIGRATION_VERSION, Report, ReportDocument, ReportSource } from './report'; - -import { reportingIlmPolicy } from './report_ilm_policy'; +import { IlmPolicyManager } from './ilm_policy_manager'; /* * When an instance of Kibana claims a report job, this information tells us about that instance @@ -92,6 +95,7 @@ export class ReportingStore { private readonly indexPrefix: string; // config setting of index prefix in system index name private readonly indexInterval: string; // config setting of index prefix: how often to poll for pending work private client?: ElasticsearchClient; + private ilmPolicyManager?: IlmPolicyManager; constructor(private reportingCore: ReportingCore, private logger: LevelLogger) { const config = reportingCore.getConfig(); @@ -109,6 +113,15 @@ export class ReportingStore { return this.client; } + private async getIlmPolicyManager() { + if (!this.ilmPolicyManager) { + const client = await this.getClient(); + this.ilmPolicyManager = IlmPolicyManager.create({ client }); + } + + return this.ilmPolicyManager; + } + private async createIndex(indexName: string) { const client = await this.getClient(); const { body: exists } = await client.indices.exists({ index: indexName }); @@ -125,7 +138,7 @@ export class ReportingStore { number_of_shards: 1, auto_expand_replicas: '0-1', lifecycle: { - name: this.ilmPolicyName, + name: ILM_POLICY_NAME, }, }, mappings: { @@ -181,37 +194,19 @@ export class ReportingStore { return client.indices.refresh({ index }); } - private readonly ilmPolicyName = 'kibana-reporting'; - - private async doesIlmPolicyExist(): Promise<boolean> { - const client = await this.getClient(); - try { - await client.ilm.getLifecycle({ policy: this.ilmPolicyName }); - return true; - } catch (e) { - if (e.statusCode === 404) { - return false; - } - throw e; - } - } - /** * Function to be called during plugin start phase. This ensures the environment is correctly * configured for storage of reports. */ public async start() { - const client = await this.getClient(); + const ilmPolicyManager = await this.getIlmPolicyManager(); try { - if (await this.doesIlmPolicyExist()) { - this.logger.debug(`Found ILM policy ${this.ilmPolicyName}; skipping creation.`); + if (await ilmPolicyManager.doesIlmPolicyExist()) { + this.logger.debug(`Found ILM policy ${ILM_POLICY_NAME}; skipping creation.`); return; } - this.logger.info(`Creating ILM policy for managing reporting indices: ${this.ilmPolicyName}`); - await client.ilm.putLifecycle({ - policy: this.ilmPolicyName, - body: reportingIlmPolicy, - }); + this.logger.info(`Creating ILM policy for managing reporting indices: ${ILM_POLICY_NAME}`); + await ilmPolicyManager.createIlmPolicy(); } catch (e) { this.logger.error('Error in start phase'); this.logger.error(e.body.error); @@ -446,4 +441,8 @@ export class ReportingStore { return body.hits?.hits[0] as ReportRecordTimeout; } + + public getReportingIndexPattern(): string { + return `${this.indexPrefix}-*`; + } } diff --git a/x-pack/plugins/reporting/server/routes/deprecations.ts b/x-pack/plugins/reporting/server/routes/deprecations.ts new file mode 100644 index 0000000000000..7a38faf60f6bb --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/deprecations.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { errors } from '@elastic/elasticsearch'; +import { + API_MIGRATE_ILM_POLICY_URL, + API_GET_ILM_POLICY_STATUS, + ILM_POLICY_NAME, +} from '../../common/constants'; +import { IlmPolicyStatusResponse } from '../../common/types'; +import { deprecations } from '../lib/deprecations'; +import { ReportingCore } from '../core'; +import { IlmPolicyManager, LevelLogger as Logger } from '../lib'; + +export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Logger) => { + const { router } = reporting.getPluginSetupDeps(); + + router.get( + { + path: API_GET_ILM_POLICY_STATUS, + validate: false, + }, + async ( + { + core: { + elasticsearch: { client: scopedClient }, + }, + }, + req, + res + ) => { + const checkIlmMigrationStatus = () => { + return deprecations.checkIlmMigrationStatus({ + reportingCore: reporting, + // We want to make the current status visible to all reporting users + elasticsearchClient: scopedClient.asInternalUser, + }); + }; + + try { + const response: IlmPolicyStatusResponse = { + status: await checkIlmMigrationStatus(), + }; + return res.ok({ body: response }); + } catch (e) { + return res.customError({ statusCode: e?.statusCode ?? 500, body: { message: e.message } }); + } + } + ); + + router.put( + { path: API_MIGRATE_ILM_POLICY_URL, validate: false }, + async ({ core: { elasticsearch } }, req, res) => { + const store = await reporting.getStore(); + const { + client: { asCurrentUser: client }, + } = elasticsearch; + + const scopedIlmPolicyManager = IlmPolicyManager.create({ + client, + }); + + // First we ensure that the reporting ILM policy exists in the cluster + try { + // We don't want to overwrite an existing reporting policy because it may contain alterations made by users + if (!(await scopedIlmPolicyManager.doesIlmPolicyExist())) { + await scopedIlmPolicyManager.createIlmPolicy(); + } + } catch (e) { + return res.customError({ statusCode: e?.statusCode ?? 500, body: { message: e.message } }); + } + + const indexPattern = store.getReportingIndexPattern(); + + // Second we migrate all of the existing indices to be managed by the reporting ILM policy + try { + await client.indices.putSettings({ + index: indexPattern, + body: { + 'index.lifecycle': { + name: ILM_POLICY_NAME, + }, + }, + }); + return res.ok(); + } catch (err) { + logger.error(err); + + if (err instanceof errors.ResponseError) { + // If there were no reporting indices to update, that's OK because then there is nothing to migrate + if (err.statusCode === 404) { + return res.ok(); + } + return res.customError({ + statusCode: err.statusCode ?? 500, + body: { + message: err.message, + name: err.name, + }, + }); + } + + throw err; + } + } + ); +}; diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index e061bd4f7d66c..a462da3849083 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -6,15 +6,17 @@ */ import { LevelLogger as Logger } from '../lib'; +import { registerDeprecationsRoutes } from './deprecations'; +import { registerDiagnosticRoutes } from './diagnostic'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; import { ReportingCore } from '../core'; -import { registerDiagnosticRoutes } from './diagnostic'; export function registerRoutes(reporting: ReportingCore, logger: Logger) { + registerDeprecationsRoutes(reporting, logger); + registerDiagnosticRoutes(reporting, logger); registerJobGenerationRoutes(reporting, logger); registerJobInfoRoutes(reporting); - registerDiagnosticRoutes(reporting, logger); } export interface ReportingRequestPre { diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts new file mode 100644 index 0000000000000..a0f4a3f91fe32 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; +import { FtrProviderContext } from '../ftr_provider_context'; + +import { ILM_POLICY_NAME } from '../../../plugins/reporting/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const supertestNoAuth = getService('supertestWithoutAuth'); + const reportingAPI = getService('reportingAPI'); + + describe('ILM policy migration APIs', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.load('x-pack/test/functional/es_archives/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + }); + + afterEach(async () => { + await reportingAPI.deleteAllReports(); + await reportingAPI.migrateReportingIndices(); // ensure that the ILM policy exists + }); + + it('detects when no migration is needed', async () => { + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + + // try creating a report + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('detects when reporting indices should be migrated due to missing ILM policy', async () => { + await reportingAPI.makeAllReportingPoliciesUnmanaged(); + // TODO: Remove "any" when no longer through type issue "policy_id" missing + await es.ilm.deleteLifecycle({ policy: ILM_POLICY_NAME } as any); + + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('policy-not-found'); + // assert that migration fixes this + await reportingAPI.migrateReportingIndices(); + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('detects when reporting indices should be migrated due to unmanaged indices', async () => { + await reportingAPI.makeAllReportingPoliciesUnmanaged(); + await supertestNoAuth + .post(`/api/reporting/generate/csv`) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); + + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('indices-not-managed-by-policy'); + // assert that migration fixes this + await reportingAPI.migrateReportingIndices(); + expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); + }); + + it('does not override an existing ILM policy', async () => { + const customLifecycle = { + policy: { + phases: { + hot: { + min_age: '0ms', + actions: {}, + }, + delete: { + min_age: '0ms', + actions: { + delete: { + delete_searchable_snapshot: true, + }, + }, + }, + }, + }, + }; + + // customize the lifecycle policy + await es.ilm.putLifecycle({ + policy: ILM_POLICY_NAME, + body: customLifecycle, + }); + + await reportingAPI.migrateReportingIndices(); + + const { + body: { + [ILM_POLICY_NAME]: { policy }, + }, + } = await es.ilm.getLifecycle({ policy: ILM_POLICY_NAME }); + + expect(policy).to.eql(customLifecycle.policy); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index 15960e45d4a62..fed842427ab90 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Reporting API Integration Tests with Security disabled', function () { this.tags('ciGroup13'); loadTestFile(require.resolve('./job_apis')); + loadTestFile(require.resolve('./ilm_migration_apis')); }); } diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index e45af4bd140b0..eb32de9d0dc9c 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -164,6 +164,36 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer }); }; + const checkIlmMigrationStatus = async () => { + log.debug('ReportingAPI.checkIlmMigrationStatus'); + const { body } = await supertestWithoutAuth + .get('/api/reporting/ilm_policy_status') + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.status; + }; + + const migrateReportingIndices = async () => { + log.debug('ReportingAPI.migrateReportingIndices'); + await supertestWithoutAuth + .put('/api/reporting/deprecations/migrate_ilm_policy') + .set('kbn-xsrf', 'xxx') + .expect(200); + }; + + const makeAllReportingPoliciesUnmanaged = async () => { + log.debug('ReportingAPI.makeAllReportingPoliciesUnmanaged'); + const settings: any = { + 'index.lifecycle.name': null, + }; + await esSupertest + .put('/.reporting*/_settings') + .send({ + settings, + }) + .expect(200); + }; + return { initEcommerce, teardownEcommerce, @@ -182,5 +212,8 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer postJob, postJobJSON, deleteAllReports, + checkIlmMigrationStatus, + migrateReportingIndices, + makeAllReportingPoliciesUnmanaged, }; } From 4a21a4939b1e6e128ad341c9fad491d5f9387858 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Mon, 5 Jul 2021 14:59:39 +0100 Subject: [PATCH 06/48] skip flaky suite (#103252) --- test/functional/apps/visualize/_tsvb_chart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 1d4d4fee0175e..ca310493960f5 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -125,7 +125,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('switch index patterns', () => { + // FLAKY: https://github.com/elastic/kibana/issues/103252 + describe.skip('switch index patterns', () => { before(async () => { await esArchiver.loadIfNeeded( 'test/functional/fixtures/es_archiver/index_pattern_without_timefield' From a36bdc2c4b3ee8dbec09491802f5230133910604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= <davidsansol92@gmail.com> Date: Mon, 5 Jul 2021 16:17:38 +0200 Subject: [PATCH 07/48] Changes flyout title, uptades tests and add more space between title form and inputs (#104284) --- .../view/components/flyout/index.test.tsx | 6 ++--- .../view/components/flyout/index.tsx | 26 ++++++++----------- .../view/components/form/index.tsx | 2 +- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx index 6a106b1488677..d5a1c6624923b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx @@ -69,9 +69,8 @@ describe('Event filter flyout', () => { it('should renders correctly', () => { const component = render(); - expect(component.getAllByText('Add Endpoint Event Filter')).not.toBeNull(); + expect(component.getAllByText('Add event filter')).not.toBeNull(); expect(component.getByText('Cancel')).not.toBeNull(); - expect(component.getByText('Endpoint Security')).not.toBeNull(); }); it('should dispatch action to init form store on mount', async () => { @@ -183,9 +182,8 @@ describe('Event filter flyout', () => { it('should renders correctly when id and edit type', () => { const component = render({ id: 'fakeId', type: 'edit' }); - expect(component.getAllByText('Update Endpoint Event Filter')).not.toBeNull(); + expect(component.getAllByText('Update event filter')).not.toBeNull(); expect(component.getByText('Cancel')).not.toBeNull(); - expect(component.getByText('Endpoint Security')).not.toBeNull(); }); it('should dispatch action to init form store on mount with id', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index 1217488a75ea6..c45741c1520b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -112,23 +112,19 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo( <EuiFlyoutHeader hasBorder> <EuiTitle size="m"> <h2> - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.title" - defaultMessage="Endpoint Security" - /> + {id ? ( + <FormattedMessage + id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update" + defaultMessage="Update event filter" + /> + ) : ( + <FormattedMessage + id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create" + defaultMessage="Add event filter" + /> + )} </h2> </EuiTitle> - {id ? ( - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update" - defaultMessage="Update Endpoint Event Filter" - /> - ) : ( - <FormattedMessage - id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create" - defaultMessage="Add Endpoint Event Filter" - /> - )} </EuiFlyoutHeader> <EuiFlyoutBody> diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index 121808b62f570..db5c42241a0cc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -206,7 +206,7 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo( return !isIndexPatternLoading && exception ? ( <EuiForm component="div"> <EuiText size="s">{FORM_DESCRIPTION}</EuiText> - <EuiSpacer size="s" /> + <EuiSpacer size="m" /> {nameInputMemo} <EuiSpacer size="m" /> {allowSelectOs ? ( From 2a37ef8c5f6ce5e92f9b5b8004a9195c11c9a945 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Mon, 5 Jul 2021 16:56:51 +0200 Subject: [PATCH 08/48] [ML] Jest unit tests for APM Latency Correlations. (#103907) Adds jest unit tests for APM Latency Correlations code. Writing the tests surfaced some minor glitches fixed as part of this PR: - Fixes a typo in the name for the fetchTransactionDurationPercentiles() function. - Avoids adding a @timestamp filter if neither start/end are set as parameters for getQueryWithParams(). - Adds a check to only push to ranges arrays if it's length is already greater than 0. - Makes the check against from more strict otherwise it wouldn't be added as an attribute if 0. - Fixes progress calculation for field/value pair fetching. - Removes leading 0 from fractions since an ES update got merged. - Removes deprecated use of track_total_hits. --- .../correlations/async_search_service.ts | 6 +- .../get_query_with_params.test.ts | 92 +++++++ .../correlations/get_query_with_params.ts | 4 + .../correlations/query_correlation.test.ts | 103 ++++++++ .../correlations/query_correlation.ts | 5 +- .../query_field_candidates.test.ts | 145 +++++++++++ .../correlations/query_field_candidates.ts | 9 +- .../query_field_value_pairs.test.ts | 78 ++++++ .../correlations/query_field_value_pairs.ts | 2 +- .../correlations/query_fractions.test.ts | 65 +++++ .../correlations/query_histogram.test.ts | 90 +++++++ .../query_histogram_interval.test.ts | 88 +++++++ .../query_histogram_rangesteps.test.ts | 90 +++++++ .../correlations/query_percentiles.test.ts | 93 +++++++ .../correlations/query_percentiles.ts | 4 +- .../correlations/query_ranges.test.ts | 124 ++++++++++ .../correlations/query_ranges.ts | 4 +- .../correlations/search_strategy.test.ts | 234 ++++++++++++++++++ .../utils/aggregation_utils.test.ts | 49 ++++ .../correlations/utils/aggregation_utils.ts | 6 +- .../correlations/utils/math_utils.test.ts | 26 ++ 21 files changed, 1299 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts index 5820fd952c449..7a511fc60fd06 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/async_search_service.ts @@ -9,7 +9,7 @@ import { shuffle, range } from 'lodash'; import type { ElasticsearchClient } from 'src/core/server'; import { fetchTransactionDurationFieldCandidates } from './query_field_candidates'; import { fetchTransactionDurationFieldValuePairs } from './query_field_value_pairs'; -import { fetchTransactionDurationPecentiles } from './query_percentiles'; +import { fetchTransactionDurationPercentiles } from './query_percentiles'; import { fetchTransactionDurationCorrelation } from './query_correlation'; import { fetchTransactionDurationHistogramRangesteps } from './query_histogram_rangesteps'; import { fetchTransactionDurationRanges, HistogramItem } from './query_ranges'; @@ -59,7 +59,7 @@ export const asyncSearchServiceProvider = ( const fetchCorrelations = async () => { try { // 95th percentile to be displayed as a marker in the log log chart - const percentileThreshold = await fetchTransactionDurationPecentiles( + const percentileThreshold = await fetchTransactionDurationPercentiles( esClient, params, params.percentileThreshold ? [params.percentileThreshold] : undefined @@ -93,7 +93,7 @@ export const asyncSearchServiceProvider = ( // Create an array of ranges [2, 4, 6, ..., 98] const percents = Array.from(range(2, 100, 2)); - const percentilesRecords = await fetchTransactionDurationPecentiles( + const percentilesRecords = await fetchTransactionDurationPercentiles( esClient, params, percents diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts new file mode 100644 index 0000000000000..12e897ab3eec9 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getQueryWithParams } from './get_query_with_params'; + +describe('correlations', () => { + describe('getQueryWithParams', () => { + it('returns the most basic query filtering on processor.event=transaction', () => { + const query = getQueryWithParams({ params: { index: 'apm-*' } }); + expect(query).toEqual({ + bool: { + filter: [{ term: { 'processor.event': 'transaction' } }], + }, + }); + }); + + it('returns a query considering additional params', () => { + const query = getQueryWithParams({ + params: { + index: 'apm-*', + serviceName: 'actualServiceName', + transactionName: 'actualTransactionName', + start: '01-01-2021', + end: '31-01-2021', + environment: 'dev', + percentileThresholdValue: 75, + }, + }); + expect(query).toEqual({ + bool: { + filter: [ + { term: { 'processor.event': 'transaction' } }, + { + term: { + 'service.name': 'actualServiceName', + }, + }, + { + term: { + 'transaction.name': 'actualTransactionName', + }, + }, + { + range: { + '@timestamp': { + gte: '01-01-2021', + lte: '31-01-2021', + }, + }, + }, + { + term: { + 'service.environment': 'dev', + }, + }, + { + range: { + 'transaction.duration.us': { + gte: 75, + }, + }, + }, + ], + }, + }); + }); + + it('returns a query considering a custom field/value pair', () => { + const query = getQueryWithParams({ + params: { index: 'apm-*' }, + fieldName: 'actualFieldName', + fieldValue: 'actualFieldValue', + }); + expect(query).toEqual({ + bool: { + filter: [ + { term: { 'processor.event': 'transaction' } }, + { + term: { + actualFieldName: 'actualFieldValue', + }, + }, + ], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts index e7cf8173b5bac..08ba4b23fec35 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/get_query_with_params.ts @@ -43,6 +43,10 @@ const getRangeQuery = ( start?: string, end?: string ): estypes.QueryDslQueryContainer[] => { + if (start === undefined && end === undefined) { + return []; + } + return [ { range: { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts new file mode 100644 index 0000000000000..24741ebaa2dae --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationCorrelation, + getTransactionDurationCorrelationRequest, + BucketCorrelation, +} from './query_correlation'; + +const params = { index: 'apm-*' }; +const expectations = [1, 3, 5]; +const ranges = [{ to: 1 }, { from: 1, to: 3 }, { from: 3, to: 5 }, { from: 5 }]; +const fractions = [1, 2, 4, 5]; +const totalDocCount = 1234; + +describe('query_correlation', () => { + describe('getTransactionDurationCorrelationRequest', () => { + it('applies options to the returned query with aggregations for correlations and k-test', () => { + const query = getTransactionDurationCorrelationRequest( + params, + expectations, + ranges, + fractions, + totalDocCount + ); + + expect(query.index).toBe(params.index); + + expect(query?.body?.aggs?.latency_ranges?.range?.field).toBe( + 'transaction.duration.us' + ); + expect(query?.body?.aggs?.latency_ranges?.range?.ranges).toEqual(ranges); + + expect( + (query?.body?.aggs?.transaction_duration_correlation as { + bucket_correlation: BucketCorrelation; + })?.bucket_correlation.function.count_correlation.indicator + ).toEqual({ + fractions, + expectations, + doc_count: totalDocCount, + }); + + expect( + (query?.body?.aggs?.ks_test as any)?.bucket_count_ks_test?.fractions + ).toEqual(fractions); + }); + }); + + describe('fetchTransactionDurationCorrelation', () => { + it('returns the data from the aggregations', async () => { + const latencyRangesBuckets = [{ to: 1 }, { from: 1, to: 2 }, { from: 2 }]; + const transactionDurationCorrelationValue = 0.45; + const KsTestLess = 0.01; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + latency_ranges: { + buckets: latencyRangesBuckets, + }, + transaction_duration_correlation: { + value: transactionDurationCorrelationValue, + }, + ks_test: { less: KsTestLess }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationCorrelation( + esClientMock, + params, + expectations, + ranges, + fractions, + totalDocCount + ); + + expect(resp).toEqual({ + correlation: transactionDurationCorrelationValue, + ksTest: KsTestLess, + ranges: latencyRangesBuckets, + }); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts index 9894ac54eccb6..f63c36f90d728 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_correlation.ts @@ -26,7 +26,7 @@ interface ResponseHit { _source: ResponseHitSource; } -interface BucketCorrelation { +export interface BucketCorrelation { buckets_path: string; function: { count_correlation: { @@ -80,8 +80,7 @@ export const getTransactionDurationCorrelationRequest = ( // KS test p value = ks_test.less ks_test: { bucket_count_ks_test: { - // Remove 0 after https://github.com/elastic/elasticsearch/pull/74624 is merged - fractions: [0, ...fractions], + fractions, buckets_path: 'latency_ranges>_count', alternative: ['less', 'greater', 'two_sided'], }, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts new file mode 100644 index 0000000000000..89bdd4280d324 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationFieldCandidates, + getRandomDocsRequest, + hasPrefixToInclude, + shouldBeExcluded, +} from './query_field_candidates'; + +const params = { index: 'apm-*' }; + +describe('query_field_candidates', () => { + describe('shouldBeExcluded', () => { + it('does not exclude a completely custom field name', () => { + expect(shouldBeExcluded('myFieldName')).toBe(false); + }); + + it(`excludes a field if it's one of FIELDS_TO_EXCLUDE_AS_CANDIDATE`, () => { + expect(shouldBeExcluded('transaction.type')).toBe(true); + }); + + it(`excludes a field if it's prefixed with one of FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE`, () => { + expect(shouldBeExcluded('observer.myFieldName')).toBe(true); + }); + }); + + describe('hasPrefixToInclude', () => { + it('identifies if a field name is prefixed to be included', () => { + expect(hasPrefixToInclude('myFieldName')).toBe(false); + expect(hasPrefixToInclude('somePrefix.myFieldName')).toBe(false); + expect(hasPrefixToInclude('cloud.myFieldName')).toBe(true); + expect(hasPrefixToInclude('labels.myFieldName')).toBe(true); + expect(hasPrefixToInclude('user_agent.myFieldName')).toBe(true); + }); + }); + + describe('getRandomDocsRequest', () => { + it('returns the most basic request body for a sample of random documents', () => { + const req = getRandomDocsRequest(params); + + expect(req).toEqual({ + body: { + _source: false, + fields: ['*'], + query: { + function_score: { + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + random_score: {}, + }, + }, + size: 1000, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationFieldCandidates', () => { + it('returns field candidates and total hits', async () => { + const esClientFieldCapsMock = jest.fn(() => ({ + body: { + fields: { + myIpFieldName: { ip: {} }, + myKeywordFieldName: { keyword: {} }, + myUnpopulatedKeywordFieldName: { keyword: {} }, + myNumericFieldName: { number: {} }, + }, + }, + })); + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + hits: { + hits: [ + { + fields: { + myIpFieldName: '1.1.1.1', + myKeywordFieldName: 'myKeywordFieldValue', + myNumericFieldName: 1234, + }, + }, + ], + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + fieldCaps: esClientFieldCapsMock, + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFieldCandidates( + esClientMock, + params + ); + + expect(resp).toEqual({ + fieldCandidates: [ + // default field candidates + 'service.version', + 'service.node.name', + 'service.framework.version', + 'service.language.version', + 'service.runtime.version', + 'kubernetes.pod.name', + 'kubernetes.pod.uid', + 'container.id', + 'source.ip', + 'client.ip', + 'host.ip', + 'service.environment', + 'process.args', + 'http.response.status_code', + // field candidates identified by sample documents + 'myIpFieldName', + 'myKeywordFieldName', + ], + }); + expect(esClientFieldCapsMock).toHaveBeenCalledTimes(1); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts index 4f1840971da7d..0fbdfef405e0d 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_candidates.ts @@ -21,7 +21,7 @@ import { POPULATED_DOC_COUNT_SAMPLE_SIZE, } from './constants'; -const shouldBeExcluded = (fieldName: string) => { +export const shouldBeExcluded = (fieldName: string) => { return ( FIELDS_TO_EXCLUDE_AS_CANDIDATE.has(fieldName) || FIELD_PREFIX_TO_EXCLUDE_AS_CANDIDATE.some((prefix) => @@ -30,7 +30,7 @@ const shouldBeExcluded = (fieldName: string) => { ); }; -const hasPrefixToInclude = (fieldName: string) => { +export const hasPrefixToInclude = (fieldName: string) => { return FIELD_PREFIX_TO_ADD_AS_CANDIDATE.some((prefix) => fieldName.startsWith(prefix) ); @@ -50,8 +50,6 @@ export const getRandomDocsRequest = ( random_score: {}, }, }, - // Required value for later correlation queries - track_total_hits: true, size: POPULATED_DOC_COUNT_SAMPLE_SIZE, }, }); @@ -59,7 +57,7 @@ export const getRandomDocsRequest = ( export const fetchTransactionDurationFieldCandidates = async ( esClient: ElasticsearchClient, params: SearchServiceParams -): Promise<{ fieldCandidates: Field[]; totalHits: number }> => { +): Promise<{ fieldCandidates: Field[] }> => { const { index } = params; // Get all fields with keyword mapping const respMapping = await esClient.fieldCaps({ @@ -100,6 +98,5 @@ export const fetchTransactionDurationFieldCandidates = async ( return { fieldCandidates: [...finalFieldCandidates], - totalHits: (resp.body.hits.total as estypes.SearchTotalHits).value, }; }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts new file mode 100644 index 0000000000000..ea5a1f55bc924 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import type { AsyncSearchProviderProgress } from '../../../../common/search_strategies/correlations/types'; + +import { + fetchTransactionDurationFieldValuePairs, + getTermsAggRequest, +} from './query_field_value_pairs'; + +const params = { index: 'apm-*' }; + +describe('query_field_value_pairs', () => { + describe('getTermsAggRequest', () => { + it('returns the most basic request body for a terms aggregation', () => { + const fieldName = 'myFieldName'; + const req = getTermsAggRequest(params, fieldName); + expect(req?.body?.aggs?.attribute_terms?.terms?.field).toBe(fieldName); + }); + }); + + describe('fetchTransactionDurationFieldValuePairs', () => { + it('returns field/value pairs for field candidates', async () => { + const fieldCandidates = [ + 'myFieldCandidate1', + 'myFieldCandidate2', + 'myFieldCandidate3', + ]; + const progress = { + loadedFieldValuePairs: 0, + } as AsyncSearchProviderProgress; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + attribute_terms: { + buckets: [{ key: 'myValue1' }, { key: 'myValue2' }], + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFieldValuePairs( + esClientMock, + params, + fieldCandidates, + progress + ); + + expect(progress.loadedFieldValuePairs).toBe(1); + expect(resp).toEqual([ + { field: 'myFieldCandidate1', value: 'myValue1' }, + { field: 'myFieldCandidate1', value: 'myValue2' }, + { field: 'myFieldCandidate2', value: 'myValue1' }, + { field: 'myFieldCandidate2', value: 'myValue2' }, + { field: 'myFieldCandidate3', value: 'myValue1' }, + { field: 'myFieldCandidate3', value: 'myValue2' }, + ]); + expect(esClientSearchMock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts index 703a203c89207..8fde9d3ab1378 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_field_value_pairs.ts @@ -52,7 +52,7 @@ export const fetchTransactionDurationFieldValuePairs = async ( ): Promise<FieldValuePairs> => { const fieldValuePairs: FieldValuePairs = []; - let fieldValuePairsProgress = 0; + let fieldValuePairsProgress = 1; for (let i = 0; i < fieldCandidates.length; i++) { const fieldName = fieldCandidates[i]; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts new file mode 100644 index 0000000000000..6052841d277c3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_fractions.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationFractions, + getTransactionDurationRangesRequest, +} from './query_fractions'; + +const params = { index: 'apm-*' }; +const ranges = [{ to: 1 }, { from: 1, to: 3 }, { from: 3, to: 5 }, { from: 5 }]; + +describe('query_fractions', () => { + describe('getTransactionDurationRangesRequest', () => { + it('returns the request body for the transaction duration ranges aggregation', () => { + const req = getTransactionDurationRangesRequest(params, ranges); + + expect(req?.body?.aggs?.latency_ranges?.range?.field).toBe( + 'transaction.duration.us' + ); + expect(req?.body?.aggs?.latency_ranges?.range?.ranges).toEqual(ranges); + }); + }); + + describe('fetchTransactionDurationFractions', () => { + it('computes the actual percentile bucket counts and actual fractions', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + latency_ranges: { + buckets: [{ doc_count: 1 }, { doc_count: 2 }], + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationFractions( + esClientMock, + params, + ranges + ); + + expect(resp).toEqual({ + fractions: [0.3333333333333333, 0.6666666666666666], + totalDocCount: 3, + }); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts new file mode 100644 index 0000000000000..2be9446352260 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogram, + getTransactionDurationHistogramRequest, +} from './query_histogram'; + +const params = { index: 'apm-*' }; +const interval = 100; + +describe('query_histogram', () => { + describe('getTransactionDurationHistogramRequest', () => { + it('returns the request body for the histogram request', () => { + const req = getTransactionDurationHistogramRequest(params, interval); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_histogram: { + histogram: { + field: 'transaction.duration.us', + interval, + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: 'apm-*', + }); + }); + }); + + describe('fetchTransactionDurationHistogram', () => { + it('returns the buckets from the histogram aggregation', async () => { + const histogramBucket = [ + { + key: 0.0, + doc_count: 1, + }, + ]; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_histogram: { + buckets: histogramBucket, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogram( + esClientMock, + params, + interval + ); + + expect(resp).toEqual(histogramBucket); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts new file mode 100644 index 0000000000000..9ed529ccabddb --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_interval.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogramInterval, + getHistogramIntervalRequest, +} from './query_histogram_interval'; + +const params = { index: 'apm-*' }; + +describe('query_histogram_interval', () => { + describe('getHistogramIntervalRequest', () => { + it('returns the request body for the transaction duration ranges aggregation', () => { + const req = getHistogramIntervalRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_max: { + max: { + field: 'transaction.duration.us', + }, + }, + transaction_duration_min: { + min: { + field: 'transaction.duration.us', + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationHistogramInterval', () => { + it('fetches the interval duration for histograms', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_max: { + value: 10000, + }, + transaction_duration_min: { + value: 10, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogramInterval( + esClientMock, + params + ); + + expect(resp).toEqual(10); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts new file mode 100644 index 0000000000000..bb366ea29fed4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_histogram_rangesteps.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationHistogramRangesteps, + getHistogramIntervalRequest, +} from './query_histogram_rangesteps'; + +const params = { index: 'apm-*' }; + +describe('query_histogram_rangesteps', () => { + describe('getHistogramIntervalRequest', () => { + it('returns the request body for the histogram interval request', () => { + const req = getHistogramIntervalRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_max: { + max: { + field: 'transaction.duration.us', + }, + }, + transaction_duration_min: { + min: { + field: 'transaction.duration.us', + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationHistogramRangesteps', () => { + it('fetches the range steps for the log histogram', async () => { + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_max: { + value: 10000, + }, + transaction_duration_min: { + value: 10, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationHistogramRangesteps( + esClientMock, + params + ); + + expect(resp.length).toEqual(100); + expect(resp[0]).toEqual(9.260965422132594); + expect(resp[99]).toEqual(18521.930844265193); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts new file mode 100644 index 0000000000000..0c319aee0fb2b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationPercentiles, + getTransactionDurationPercentilesRequest, +} from './query_percentiles'; + +const params = { index: 'apm-*' }; + +describe('query_percentiles', () => { + describe('getTransactionDurationPercentilesRequest', () => { + it('returns the request body for the duration percentiles request', () => { + const req = getTransactionDurationPercentilesRequest(params); + + expect(req).toEqual({ + body: { + aggs: { + transaction_duration_percentiles: { + percentiles: { + field: 'transaction.duration.us', + hdr: { + number_of_significant_value_digits: 3, + }, + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationPercentiles', () => { + it('fetches the percentiles', async () => { + const percentilesValues = { + '1.0': 5.0, + '5.0': 25.0, + '25.0': 165.0, + '50.0': 445.0, + '75.0': 725.0, + '95.0': 945.0, + '99.0': 985.0, + }; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + transaction_duration_percentiles: { + values: percentilesValues, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationPercentiles( + esClientMock, + params + ); + + expect(resp).toEqual(percentilesValues); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts index 013c1ba3cbc23..18dcefb59a11a 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_percentiles.ts @@ -55,7 +55,7 @@ export const getTransactionDurationPercentilesRequest = ( }; }; -export const fetchTransactionDurationPecentiles = async ( +export const fetchTransactionDurationPercentiles = async ( esClient: ElasticsearchClient, params: SearchServiceParams, percents?: number[], @@ -73,7 +73,7 @@ export const fetchTransactionDurationPecentiles = async ( if (resp.body.aggregations === undefined) { throw new Error( - 'fetchTransactionDurationPecentiles failed, did not return aggregations.' + 'fetchTransactionDurationPercentiles failed, did not return aggregations.' ); } return ( diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts new file mode 100644 index 0000000000000..9451928e47ded --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import type { ElasticsearchClient } from 'src/core/server'; + +import { + fetchTransactionDurationRanges, + getTransactionDurationRangesRequest, +} from './query_ranges'; + +const params = { index: 'apm-*' }; +const rangeSteps = [1, 3, 5]; + +describe('query_ranges', () => { + describe('getTransactionDurationRangesRequest', () => { + it('returns the request body for the duration percentiles request', () => { + const req = getTransactionDurationRangesRequest(params, rangeSteps); + + expect(req).toEqual({ + body: { + aggs: { + logspace_ranges: { + range: { + field: 'transaction.duration.us', + ranges: [ + { + to: 0, + }, + { + from: 0, + to: 1, + }, + { + from: 1, + to: 3, + }, + { + from: 3, + to: 5, + }, + { + from: 5, + }, + ], + }, + }, + }, + query: { + bool: { + filter: [ + { + term: { + 'processor.event': 'transaction', + }, + }, + ], + }, + }, + size: 0, + }, + index: params.index, + }); + }); + }); + + describe('fetchTransactionDurationRanges', () => { + it('fetches the percentiles', async () => { + const logspaceRangesBuckets = [ + { + key: '*-100.0', + to: 100.0, + doc_count: 2, + }, + { + key: '100.0-200.0', + from: 100.0, + to: 200.0, + doc_count: 2, + }, + { + key: '200.0-*', + from: 200.0, + doc_count: 3, + }, + ]; + + const esClientSearchMock = jest.fn((req: estypes.SearchRequest): { + body: estypes.SearchResponse; + } => { + return { + body: ({ + aggregations: { + logspace_ranges: { + buckets: logspaceRangesBuckets, + }, + }, + } as unknown) as estypes.SearchResponse, + }; + }); + + const esClientMock = ({ + search: esClientSearchMock, + } as unknown) as ElasticsearchClient; + + const resp = await fetchTransactionDurationRanges( + esClientMock, + params, + rangeSteps + ); + + expect(resp).toEqual([ + { doc_count: 2, key: 100 }, + { doc_count: 3, key: 200 }, + ]); + expect(esClientSearchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts index 88256f79150fc..9074e7e0809bf 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/query_ranges.ts @@ -42,7 +42,9 @@ export const getTransactionDurationRangesRequest = ( }, [{ to: 0 }] as Array<{ from?: number; to?: number }> ); - ranges.push({ from: ranges[ranges.length - 1].to }); + if (ranges.length > 0) { + ranges.push({ from: ranges[ranges.length - 1].to }); + } return { index: params.index, diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts new file mode 100644 index 0000000000000..6d4bfcdde9994 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/search_strategy.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; + +import { SearchStrategyDependencies } from 'src/plugins/data/server'; + +import { + apmCorrelationsSearchStrategyProvider, + PartialSearchRequest, +} from './search_strategy'; + +// helper to trigger promises in the async search service +const flushPromises = () => new Promise(setImmediate); + +const clientFieldCapsMock = () => ({ body: { fields: [] } }); + +// minimal client mock to fulfill search requirements of the async search service to succeed +const clientSearchMock = ( + req: estypes.SearchRequest +): { body: estypes.SearchResponse } => { + let aggregations: + | { + transaction_duration_percentiles: estypes.AggregationsTDigestPercentilesAggregate; + } + | { + transaction_duration_min: estypes.AggregationsValueAggregate; + transaction_duration_max: estypes.AggregationsValueAggregate; + } + | { + logspace_ranges: estypes.AggregationsMultiBucketAggregate<{ + from: number; + doc_count: number; + }>; + } + | { + latency_ranges: estypes.AggregationsMultiBucketAggregate<{ + doc_count: number; + }>; + } + | undefined; + + if (req?.body?.aggs !== undefined) { + const aggs = req.body.aggs; + // fetchTransactionDurationPercentiles + if (aggs.transaction_duration_percentiles !== undefined) { + aggregations = { transaction_duration_percentiles: { values: {} } }; + } + + // fetchTransactionDurationHistogramInterval + if ( + aggs.transaction_duration_min !== undefined && + aggs.transaction_duration_max !== undefined + ) { + aggregations = { + transaction_duration_min: { value: 0 }, + transaction_duration_max: { value: 1234 }, + }; + } + + // fetchTransactionDurationCorrelation + if (aggs.logspace_ranges !== undefined) { + aggregations = { logspace_ranges: { buckets: [] } }; + } + + // fetchTransactionDurationFractions + if (aggs.latency_ranges !== undefined) { + aggregations = { latency_ranges: { buckets: [] } }; + } + } + + return { + body: { + _shards: { + failed: 0, + successful: 1, + total: 1, + }, + took: 162, + timed_out: false, + hits: { + hits: [], + total: { + value: 0, + relation: 'eq', + }, + }, + ...(aggregations !== undefined ? { aggregations } : {}), + }, + }; +}; + +describe('APM Correlations search strategy', () => { + describe('strategy interface', () => { + it('returns a custom search strategy with a `search` and `cancel` function', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + expect(typeof searchStrategy.search).toBe('function'); + expect(typeof searchStrategy.cancel).toBe('function'); + }); + }); + + describe('search', () => { + let mockClientFieldCaps: jest.Mock; + let mockClientSearch: jest.Mock; + let mockDeps: SearchStrategyDependencies; + let params: Required<PartialSearchRequest>['params']; + + beforeEach(() => { + mockClientFieldCaps = jest.fn(clientFieldCapsMock); + mockClientSearch = jest.fn(clientSearchMock); + mockDeps = ({ + esClient: { + asCurrentUser: { + fieldCaps: mockClientFieldCaps, + search: mockClientSearch, + }, + }, + } as unknown) as SearchStrategyDependencies; + params = { + index: 'apm-*', + }; + }); + + describe('async functionality', () => { + describe('when no params are provided', () => { + it('throws an error', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + expect(() => searchStrategy.search({}, {}, mockDeps)).toThrow( + 'Invalid request parameters.' + ); + }); + }); + + describe('when no ID is provided', () => { + it('performs a client search with params', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + await searchStrategy.search({ params }, {}, mockDeps).toPromise(); + const [[request]] = mockClientSearch.mock.calls; + + expect(request.index).toEqual('apm-*'); + expect(request.body).toEqual( + expect.objectContaining({ + aggs: { + transaction_duration_percentiles: { + percentiles: { + field: 'transaction.duration.us', + hdr: { number_of_significant_value_digits: 3 }, + }, + }, + }, + query: { + bool: { + filter: [{ term: { 'processor.event': 'transaction' } }], + }, + }, + size: 0, + }) + ); + }); + }); + + describe('when an ID with params is provided', () => { + it('retrieves the current request', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + const response = await searchStrategy + .search({ id: 'my-search-id', params }, {}, mockDeps) + .toPromise(); + + expect(response).toEqual( + expect.objectContaining({ id: 'my-search-id' }) + ); + }); + }); + + describe('if the client throws', () => { + it('does not emit an error', async () => { + mockClientSearch + .mockReset() + .mockRejectedValueOnce(new Error('client error')); + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + const response = await searchStrategy + .search({ params }, {}, mockDeps) + .toPromise(); + + expect(response).toEqual( + expect.objectContaining({ isRunning: true }) + ); + }); + }); + + it('triggers the subscription only once', async () => { + expect.assertions(1); + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + searchStrategy + .search({ params }, {}, mockDeps) + .subscribe((response) => { + expect(response).toEqual( + expect.objectContaining({ loaded: 0, isRunning: true }) + ); + }); + }); + }); + + describe('response', () => { + it('sends an updated response on consecutive search calls', async () => { + const searchStrategy = await apmCorrelationsSearchStrategyProvider(); + + const response1 = await searchStrategy + .search({ params }, {}, mockDeps) + .toPromise(); + + expect(typeof response1.id).toEqual('string'); + expect(response1).toEqual( + expect.objectContaining({ loaded: 0, isRunning: true }) + ); + + await flushPromises(); + + const response2 = await searchStrategy + .search({ id: response1.id, params }, {}, mockDeps) + .toPromise(); + + expect(response2.id).toEqual(response1.id); + expect(response2).toEqual( + expect.objectContaining({ loaded: 10, isRunning: false }) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts new file mode 100644 index 0000000000000..63de0a59d4894 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { computeExpectationsAndRanges } from './aggregation_utils'; + +describe('aggregation utils', () => { + describe('computeExpectationsAndRanges', () => { + it('returns expectations and ranges based on given percentiles #1', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([0, 1]); + expect(expectations).toEqual([0, 0.5, 1]); + expect(ranges).toEqual([{ to: 0 }, { from: 0, to: 1 }, { from: 1 }]); + }); + it('returns expectations and ranges based on given percentiles #2', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([1, 3, 5]); + expect(expectations).toEqual([1, 2, 4, 5]); + expect(ranges).toEqual([ + { to: 1 }, + { from: 1, to: 3 }, + { from: 3, to: 5 }, + { from: 5 }, + ]); + }); + it('returns expectations and ranges with adjusted fractions', async () => { + const { expectations, ranges } = computeExpectationsAndRanges([ + 1, + 3, + 3, + 5, + ]); + expect(expectations).toEqual([ + 1, + 2.333333333333333, + 3.666666666666667, + 5, + ]); + expect(ranges).toEqual([ + { to: 1 }, + { from: 1, to: 3 }, + { from: 3, to: 3 }, + { from: 3, to: 5 }, + { from: 5 }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts index 34e5ae2795d58..8d83b8fc29b05 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/aggregation_utils.ts @@ -31,14 +31,16 @@ export const computeExpectationsAndRanges = ( const ranges = percentiles.reduce((p, to) => { const from = p[p.length - 1]?.to; - if (from) { + if (from !== undefined) { p.push({ from, to }); } else { p.push({ to }); } return p; }, [] as Array<{ from?: number; to?: number }>); - ranges.push({ from: ranges[ranges.length - 1].to }); + if (ranges.length > 0) { + ranges.push({ from: ranges[ranges.length - 1].to }); + } const expectations = [tempPercentiles[0]]; for (let i = 1; i < tempPercentiles.length; i++) { diff --git a/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.ts new file mode 100644 index 0000000000000..ed4107b9d602a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/correlations/utils/math_utils.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRandomInt } from './math_utils'; + +describe('math utils', () => { + describe('getRandomInt', () => { + it('returns a random integer within the given range', () => { + const min = 0.9; + const max = 11.1; + const randomInt = getRandomInt(min, max); + expect(Number.isInteger(randomInt)).toBe(true); + expect(randomInt > min).toBe(true); + expect(randomInt < max).toBe(true); + }); + + it('returns 1 if given range only allows this integer', () => { + const randomInt = getRandomInt(0.9, 1.1); + expect(randomInt).toBe(1); + }); + }); +}); From 1b92c29d18959f571c18bdb1447f8dc1ed04080a Mon Sep 17 00:00:00 2001 From: Vadim Yakhin <yakhin.v@gmail.com> Date: Mon, 5 Jul 2021 12:45:43 -0300 Subject: [PATCH 09/48] Fix Overview page crashing (#104258) The activity feed tried to render a link to a source for activity items like "New user joined organization". This couldn't be done and the page crashed. This PR accounts for this case by rendering an item with no link for such cases. This was already fixed before in ent-search in https://github.com/elastic/ent-search/pull/2574 But we didn't port the PR, because we thought there is no need to port a part of Standard auth functionality. Turned out there is. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../views/overview/recent_activity.test.tsx | 14 +++++-- .../views/overview/recent_activity.tsx | 42 +++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx index 0cb3f77c681a1..78e4954a4bf16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx @@ -30,6 +30,11 @@ const activityFeed = [ target: 'http://localhost:3002/ws/org/sources', timestamp: '2020-06-24 16:34:16', }, + { + id: '(foo@example.com)', + message: 'joined the organization', + timestamp: '2021-07-02 16:38:27', + }, ]; describe('RecentActivity', () => { @@ -46,13 +51,14 @@ describe('RecentActivity', () => { it('renders an activityFeed with links', () => { setMockValues({ activityFeed }); const wrapper = shallow(<RecentActivity />); - const activity = wrapper.find(RecentActivityItem).dive(); + const sourceActivityItem = wrapper.find(RecentActivityItem).first().dive(); + const newUserActivityItem = wrapper.find(RecentActivityItem).last().dive(); - expect(activity).toHaveLength(1); - - const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + const link = sourceActivityItem.find('[data-test-subj="viewSourceDetailsLink"]'); link.simulate('click'); expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + + expect(newUserActivityItem.find('[data-test-subj="newUserTextWrapper"]')).toHaveLength(1); }); it('renders activity item error state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 8bda7c2843b9e..51a6508986037 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -29,7 +29,7 @@ export interface FeedActivity { id: string; message: string; timestamp: string; - sourceId: string; + sourceId?: string; } export const RecentActivity: React.FC = () => { @@ -98,23 +98,29 @@ export const RecentActivityItem: React.FC<FeedActivity> = ({ return ( <div className={`activity ${status ? `activity--${status}` : ''}`}> <div className="activity__message"> - <EuiLinkTo - onClick={onClick} - color={status === 'error' ? 'danger' : 'primary'} - to={getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)} - data-test-subj="viewSourceDetailsLink" - > - {id} {message} - {status === 'error' && ( - <span className="activity--error__label"> - {' '} - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.recentActivitySourceLink.linkLabel" - defaultMessage="View Source" - /> - </span> - )} - </EuiLinkTo> + {sourceId ? ( + <EuiLinkTo + onClick={onClick} + color={status === 'error' ? 'danger' : 'primary'} + to={getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)} + data-test-subj="viewSourceDetailsLink" + > + {id} {message} + {status === 'error' && ( + <span className="activity--error__label"> + {' '} + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.recentActivitySourceLink.linkLabel" + defaultMessage="View Source" + /> + </span> + )} + </EuiLinkTo> + ) : ( + <div data-test-subj="newUserTextWrapper"> + {id} {message} + </div> + )} </div> <div className="activity__date">{moment.utc(timestamp).fromNow()}</div> </div> From a0b96d4faba6bd2bc5fe4d9b07485b8dc95534cf Mon Sep 17 00:00:00 2001 From: Domenico Andreoli <domenico.andreoli@elastic.co> Date: Mon, 5 Jul 2021 18:05:01 +0200 Subject: [PATCH 10/48] Override cy.exec's NODE_TLS_REJECT_UNAUTHORIZED=0 (#104256) If any of Elasticsearch or Kibana urls uses https, then `node` aborts with: > Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes > TLS connections and HTTPS requests insecure by disabling certificate verification. Investigation proved that cy.exec injects `NODE_TLS_REJECT_UNAUTHORIZED=0` regardless of how `NODE_TLS_REJECT_UNAUTHORIZED` or `CYPRESS_NODE_TLS_REJECT_UNAUTHORIZED` are defined in the environment. 1. Use `node --no-warnings` Gun too big, could hide other issues worthy of a failure 2. Explicitly pass NODE_TLS_REJECT_UNAUTHORIZED=1 This commit implements option 2. ``` describe('Env checks', () => { it('CYPRESS_NODE_TLS_REJECT_UNAUTHORIZED is undefined', () => { expect(Cypress.env('NODE_TLS_REJECT_UNAUTHORIZED')).to.equal(undefined); }); it('NODE_TLS_REJECT_UNAUTHORIZED is undefined', () => { expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).to.equal(undefined); }); it('cy.exec environment is sane', () => { const NODE_TLS_REJECT_UNAUTHORIZED = Cypress.env('NODE_TLS_REJECT_UNAUTHORIZED') === '0' ? '0' : '1'; cy.exec('set', { env: { NODE_TLS_REJECT_UNAUTHORIZED } }) .its('stdout') .should(($elem) => { expect($elem).to.not.contain('NODE_TLS_REJECT_UNAUTHORIZED=0') }); }); }); ``` --- .../plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts | 11 ++++++++--- x-pack/plugins/security_solution/cypress/README.md | 10 +++++++++- .../security_solution/cypress/tasks/es_archiver.ts | 11 ++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts index 25090e14ebf91..3912b60dd56ed 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts @@ -9,23 +9,28 @@ import Path from 'path'; const ES_ARCHIVE_DIR = './cypress/fixtures/es_archiver'; +// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +const NODE_TLS_REJECT_UNAUTHORIZED = '1'; + export const esArchiverLoad = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js` + `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverUnload = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js` + `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverResetKibana = () => { cy.exec( `node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.js`, - { failOnNonZeroExit: false } + { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index f4cddfe4d8da9..0713716a15d51 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -174,7 +174,7 @@ Represents all the URLs used during the tests execution. The data the tests need: - Is generated on the fly using our application APIs (preferred way) -- Is ingested on the ELS instance using the `es_archive` utility +- Is ingested on the ELS instance using the `es_archiver` utility By default, when running the tests in Jenkins mode, a base set of data is ingested on the ELS instance: an empty kibana index and a set of auditbeat data (the `empty_kibana` and `auditbeat` archives, respectively). This is usually enough to cover most of the scenarios that we are testing. @@ -200,6 +200,14 @@ node ../../../scripts/es_archiver save custom_rules ".kibana",".siem-signal*" - Note that the command will create the folder if it does not exist. +### Using an archive from within the Cypress tests + +Task [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts) provides helpers such as `esArchiverLoad` and `esArchiverUnload` by means of `es_archiver`'s CLI. + +Because of `cy.exec`, used to invoke `es_archiver`, it's necessary to override its environment with `NODE_TLS_REJECT_UNAUTHORIZED=1`. It indeed would inject `NODE_TLS_REJECT_UNAUTHORIZED=0` and make `es_archive` otherwise abort with the following warning if used over https: + +> Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification. + ## Development Best Practices ### Clean up the state diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index a0ee5bda82b01..94ac8003c0d8b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -12,23 +12,28 @@ const CONFIG_PATH = '../../test/functional/config.js'; const ES_URL = Cypress.env('ELASTICSEARCH_URL'); const KIBANA_URL = Cypress.config().baseUrl; +// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +const NODE_TLS_REJECT_UNAUTHORIZED = '1'; + export const esArchiverLoad = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"` + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverUnload = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); cy.exec( - `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"` + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverResetKibana = () => { cy.exec( `node ../../../scripts/es_archiver empty-kibana-index --config "${CONFIG_PATH}" --es-url "${ES_URL}" --kibana-url "${KIBANA_URL}"`, - { failOnNonZeroExit: false } + { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; From 5301403594108c5996122cf723faede0cb0bee2f Mon Sep 17 00:00:00 2001 From: James Gowdy <jgowdy@elastic.co> Date: Mon, 5 Jul 2021 17:53:18 +0100 Subject: [PATCH 11/48] [ML] Fix slow calendar creation UI (#104248) * [ML] Fix slow calendar creation UI * updating jest snapshots * updaing jest tests * fixing functional tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/new_calendar.test.js.snap | 1 + .../__snapshots__/calendar_form.test.js.snap | 1 + .../edit/calendar_form/calendar_form.js | 20 +++++++++++++------ .../edit/events_table/events_table.js | 14 ++++++++----- .../settings/calendars/edit/new_calendar.js | 2 ++ .../calendars/edit/new_calendar.test.js | 10 ++++------ .../apps/ml/settings/calendar_creation.ts | 4 ++++ .../services/ml/settings_calendar.ts | 5 +++++ 8 files changed, 40 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap index c9316dcd45ef7..793bf3a4f8ef9 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/__snapshots__/new_calendar.test.js.snap @@ -26,6 +26,7 @@ exports[`NewCalendar Renders new calendar form 1`] = ` isGlobalCalendar={false} isNewCalendarIdValid={true} jobIds={Array []} + loading={true} onCalendarIdChange={[Function]} onCreate={[Function]} onCreateGroupOption={[Function]} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap index 49caddfd29f82..410017795c6cf 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -192,6 +192,7 @@ exports[`CalendarForm Renders calendar form 1`] = ` canDeleteCalendar={true} eventsList={Array []} onDeleteClick={[MockFunction]} + saving={false} showImportModal={[MockFunction]} showNewEventModal={[MockFunction]} showSearchBar={true} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js index da131e2e998fa..1792f5324396a 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/calendar_form.js @@ -69,6 +69,7 @@ export const CalendarForm = ({ showImportModal, onJobSelection, saving, + loading, selectedGroupOptions, selectedJobOptions, showNewEventModal, @@ -83,7 +84,11 @@ export const CalendarForm = ({ const helpText = isNewCalendarIdValid === true && !isEdit ? msg : undefined; const error = isNewCalendarIdValid === false && !isEdit ? [msg] : undefined; const saveButtonDisabled = - canCreateCalendar === false || saving || !isNewCalendarIdValid || calendarId === ''; + canCreateCalendar === false || + saving || + !isNewCalendarIdValid || + calendarId === '' || + loading === true; const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); return ( @@ -116,7 +121,7 @@ export const CalendarForm = ({ name="calendarId" value={calendarId} onChange={onCalendarIdChange} - disabled={isEdit === true || saving === true} + disabled={isEdit === true || saving === true || loading === true} data-test-subj="mlCalendarIdInput" /> </EuiFormRow> @@ -133,7 +138,7 @@ export const CalendarForm = ({ name="description" value={description} onChange={onDescriptionChange} - disabled={isEdit === true || saving === true} + disabled={isEdit === true || saving === true || loading === true} data-test-subj="mlCalendarDescriptionInput" /> </EuiFormRow> @@ -152,7 +157,7 @@ export const CalendarForm = ({ } checked={isGlobalCalendar} onChange={onGlobalCalendarChange} - disabled={saving === true || canCreateCalendar === false} + disabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarApplyToAllJobsSwitch" /> @@ -172,7 +177,7 @@ export const CalendarForm = ({ options={jobIds} selectedOptions={selectedJobOptions} onChange={onJobSelection} - isDisabled={saving === true || canCreateCalendar === false} + isDisabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarJobSelection" /> </EuiFormRow> @@ -190,7 +195,7 @@ export const CalendarForm = ({ options={groupIds} selectedOptions={selectedGroupOptions} onChange={onGroupSelection} - isDisabled={saving === true || canCreateCalendar === false} + isDisabled={saving === true || canCreateCalendar === false || loading === true} data-test-subj="mlCalendarJobGroupSelection" /> </EuiFormRow> @@ -215,6 +220,8 @@ export const CalendarForm = ({ onDeleteClick={onEventDelete} showImportModal={showImportModal} showNewEventModal={showNewEventModal} + loading={loading} + saving={saving} showSearchBar /> </EuiFormRow> @@ -272,6 +279,7 @@ CalendarForm.propTypes = { showImportModal: PropTypes.func.isRequired, onJobSelection: PropTypes.func.isRequired, saving: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, selectedGroupOptions: PropTypes.array.isRequired, selectedJobOptions: PropTypes.array.isRequired, showNewEventModal: PropTypes.func.isRequired, diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js index 4f5364136f923..ad5de1f92dae5 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/events_table/events_table.js @@ -15,14 +15,14 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; -function DeleteButton({ onClick, canDeleteCalendar, testSubj }) { +function DeleteButton({ onClick, testSubj, disabled }) { return ( <Fragment> <EuiButtonEmpty size="xs" color="danger" onClick={onClick} - isDisabled={canDeleteCalendar === false} + isDisabled={disabled} data-test-subj={testSubj} > <FormattedMessage @@ -42,6 +42,8 @@ export const EventsTable = ({ showSearchBar, showImportModal, showNewEventModal, + loading, + saving, }) => { const sorting = { sort: { @@ -93,7 +95,7 @@ export const EventsTable = ({ render: (event) => ( <DeleteButton testSubj="mlCalendarEventDeleteButton" - canDeleteCalendar={canDeleteCalendar} + disabled={canDeleteCalendar === false || saving === true || loading === true} onClick={() => { onDeleteClick(event.event_id); }} @@ -105,7 +107,7 @@ export const EventsTable = ({ const search = { toolsRight: [ <EuiButton - isDisabled={canCreateCalendar === false} + isDisabled={canCreateCalendar === false || saving === true || loading === true} key="ml_new_event" data-test-subj="mlCalendarNewEventButton" size="s" @@ -118,7 +120,7 @@ export const EventsTable = ({ /> </EuiButton>, <EuiButton - isDisabled={canCreateCalendar === false} + isDisabled={canCreateCalendar === false || saving === true || loading === true} key="ml_import_event" data-test-subj="mlCalendarImportEventsButton" size="s" @@ -164,6 +166,8 @@ EventsTable.propTypes = { showImportModal: PropTypes.func, showNewEventModal: PropTypes.func, showSearchBar: PropTypes.bool, + loading: PropTypes.bool, + saving: PropTypes.bool, }; EventsTable.defaultProps = { diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 42d8b32691c20..0b31dad24a2e3 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -325,6 +325,7 @@ class NewCalendarUI extends Component { groupIdOptions, jobIdOptions, saving, + loading, selectedCalendar, selectedJobOptions, selectedGroupOptions, @@ -377,6 +378,7 @@ class NewCalendarUI extends Component { showImportModal={this.showImportModal} onJobSelection={this.onJobSelection} saving={saving} + loading={loading} selectedGroupOptions={selectedGroupOptions} selectedJobOptions={selectedJobOptions} onCreateGroupOption={this.onCreateGroupOption} diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js index 1ca731e09f7e6..4d09a17a302dd 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js @@ -132,24 +132,22 @@ describe('NewCalendar', () => { expect(wrapper).toMatchSnapshot(); }); - test('Import modal shown on Import Events button click', () => { + test('Import modal button is disabled', () => { const wrapper = mountWithIntl(<NewCalendar {...props} />); const importButton = wrapper.find('[data-test-subj="mlCalendarImportEventsButton"]'); const button = importButton.find('EuiButton'); - button.simulate('click'); - - expect(wrapper.state('isImportModalVisible')).toBe(true); + expect(button.prop('isDisabled')).toBe(true); }); - test('New event modal shown on New event button click', () => { + test('New event modal button is disabled', () => { const wrapper = mountWithIntl(<NewCalendar {...props} />); const importButton = wrapper.find('[data-test-subj="mlCalendarNewEventButton"]'); const button = importButton.find('EuiButton'); button.simulate('click'); - expect(wrapper.state('isNewEventModalVisible')).toBe(true); + expect(button.prop('isDisabled')).toBe(true); }); test('isDuplicateId returns true if form calendar id already exists in calendars', () => { diff --git a/x-pack/test/functional/apps/ml/settings/calendar_creation.ts b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts index 242163d83c456..5cee16c10e215 100644 --- a/x-pack/test/functional/apps/ml/settings/calendar_creation.ts +++ b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts @@ -46,6 +46,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); await ml.settingsCalendar.navigateToCalendarCreationPage(); + await ml.settingsCalendar.waitForFormEnabled(); + await ml.testExecution.logTestStep('calendar creation sets calendar to apply to all jobs'); await ml.settingsCalendar.toggleApplyToAllJobsSwitch(true); await ml.settingsCalendar.assertJobSelectionNotExists(); @@ -79,6 +81,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); await ml.settingsCalendar.navigateToCalendarCreationPage(); + await ml.settingsCalendar.waitForFormEnabled(); + await ml.testExecution.logTestStep( 'calendar creation verifies the job selection and job group section are displayed' ); diff --git a/x-pack/test/functional/services/ml/settings_calendar.ts b/x-pack/test/functional/services/ml/settings_calendar.ts index b2fc121f99936..8959e93623c1c 100644 --- a/x-pack/test/functional/services/ml/settings_calendar.ts +++ b/x-pack/test/functional/services/ml/settings_calendar.ts @@ -214,6 +214,11 @@ export function MachineLearningSettingsCalendarProvider( ); }, + async waitForFormEnabled() { + // @ts-expect-error null is acceptable for a disabled attribute that no longer exists. + await testSubjects.waitForAttributeToChange('mlCalendarIdInput', 'disabled', null); + }, + async assertCalendarRowExists(calendarId: string) { await testSubjects.existOrFail(this.calendarRowSelector(calendarId)); }, From 10ea44b24c35d65805923fd9e38ad3fb67000bac Mon Sep 17 00:00:00 2001 From: James Gowdy <jgowdy@elastic.co> Date: Mon, 5 Jul 2021 18:01:41 +0100 Subject: [PATCH 12/48] [ML] Fixing missing data visualizer links (#103932) * [ML] Fixing missing data visualizer links * adding index dataviz links * fixing permission * re-enabling tests * fixing typo * adding check for non time based index * catching possible error when getting index pattern Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data_visualizer/public/api/index.ts | 9 +- .../filebeat_config_flyout.tsx | 7 +- .../common/components/results_links/index.ts | 2 +- .../results_links/results_links.tsx | 46 +++++++ .../file_data_visualizer_view.js | 1 + .../components/import_view/import_view.js | 1 + .../file_data_visualizer.tsx | 8 +- .../actions_panel/actions_panel.tsx | 53 ++++++++- .../index_data_visualizer_view.tsx | 5 +- .../index_data_visualizer.tsx | 7 +- .../plugins/data_visualizer/public/index.ts | 7 +- .../file_based/file_datavisualizer.tsx | 89 +++++++++++++- .../index_based/index_data_visualizer.tsx | 112 ++++++++++++++++-- .../data_visualizer/file_data_visualizer.ts | 3 + .../index_data_visualizer_actions_panel.ts | 52 ++++---- .../apps/ml/permissions/full_ml_access.ts | 13 +- .../apps/ml/permissions/read_ml_access.ts | 9 +- .../services/ml/data_visualizer_file_based.ts | 5 + .../apps/ml/permissions/full_ml_access.ts | 9 +- .../apps/ml/permissions/read_ml_access.ts | 10 +- 20 files changed, 370 insertions(+), 78 deletions(-) diff --git a/x-pack/plugins/data_visualizer/public/api/index.ts b/x-pack/plugins/data_visualizer/public/api/index.ts index 746b43ac86e30..3b96e4caad340 100644 --- a/x-pack/plugins/data_visualizer/public/api/index.ts +++ b/x-pack/plugins/data_visualizer/public/api/index.ts @@ -8,11 +8,12 @@ import { lazyLoadModules } from '../lazy_load_bundle'; import type { FileDataVisualizerSpec, IndexDataVisualizerSpec } from '../application'; -export async function getFileDataVisualizerComponent(): Promise<FileDataVisualizerSpec> { +export async function getFileDataVisualizerComponent(): Promise<() => FileDataVisualizerSpec> { const modules = await lazyLoadModules(); - return modules.FileDataVisualizer; + return () => modules.FileDataVisualizer; } -export async function getIndexDataVisualizerComponent(): Promise<IndexDataVisualizerSpec> { + +export async function getIndexDataVisualizerComponent(): Promise<() => IndexDataVisualizerSpec> { const modules = await lazyLoadModules(); - return modules.IndexDataVisualizer; + return () => modules.IndexDataVisualizer; } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx index 6c9df5cf2eba7..e43199fabf76c 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -73,7 +73,12 @@ export const FilebeatConfigFlyout: FC<Props> = ({ <EuiFlyoutFooter> <EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexItem grow={false}> - <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> + <EuiButtonEmpty + iconType="cross" + onClick={closeFlyout} + flush="left" + data-test-subj="fileBeatConfigFlyoutCloseButton" + > <FormattedMessage id="xpack.dataVisualizer.fileBeatConfigFlyout.closeButton" defaultMessage="Close" diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/index.ts b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/index.ts index 59cb9df6f9ce6..1dca4b7bf2254 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/index.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ResultsLinks } from './results_links'; +export { ResultsLinks, ResultLink } from './results_links'; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx index a674446397db9..24918432601b6 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx @@ -18,6 +18,19 @@ import { FindFileStructureResponse } from '../../../../../../file_upload/common' import type { FileUploadPluginStart } from '../../../../../../file_upload/public'; import { useDataVisualizerKibana } from '../../../kibana_context'; +type LinkType = 'file' | 'index'; + +export interface ResultLink { + id: string; + type: LinkType; + title: string; + icon: string; + description: string; + getUrl(params?: any): Promise<string>; + canDisplay(params?: any): Promise<boolean>; + dataTestSubj?: string; +} + interface Props { fieldStats: FindFileStructureResponse['field_stats']; index: string; @@ -25,6 +38,7 @@ interface Props { timeFieldName?: string; createIndexPattern: boolean; showFilebeatFlyout(): void; + additionalLinks: ResultLink[]; } interface GlobalState { @@ -41,6 +55,7 @@ export const ResultsLinks: FC<Props> = ({ timeFieldName, createIndexPattern, showFilebeatFlyout, + additionalLinks, }) => { const { services: { fileUpload }, @@ -55,6 +70,7 @@ export const ResultsLinks: FC<Props> = ({ const [discoverLink, setDiscoverLink] = useState(''); const [indexManagementLink, setIndexManagementLink] = useState(''); const [indexPatternManagementLink, setIndexPatternManagementLink] = useState(''); + const [generatedLinks, setGeneratedLinks] = useState<Record<string, string>>({}); const { services: { @@ -100,6 +116,23 @@ export const ResultsLinks: FC<Props> = ({ getDiscoverUrl(); + Promise.all( + additionalLinks.map(async ({ canDisplay, getUrl }) => { + if ((await canDisplay({ indexPatternId })) === false) { + return null; + } + return getUrl({ globalState, indexPatternId }); + }) + ).then((urls) => { + const linksById = urls.reduce((acc, url, i) => { + if (url !== null) { + acc[additionalLinks[i].id] = url; + } + return acc; + }, {} as Record<string, string>); + setGeneratedLinks(linksById); + }); + if (!unmounted) { setIndexManagementLink( getUrlForApp('management', { path: '/data/index_management/indices' }) @@ -231,6 +264,19 @@ export const ResultsLinks: FC<Props> = ({ onClick={showFilebeatFlyout} /> </EuiFlexItem> + {additionalLinks + .filter(({ id }) => generatedLinks[id] !== undefined) + .map((link) => ( + <EuiFlexItem> + <EuiCard + icon={<EuiIcon size="xxl" type={link.icon} />} + data-test-subj="fileDataVisLink" + title={link.title} + description={link.description} + href={generatedLinks[link.id]} + /> + </EuiFlexItem> + ))} </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js index 99b6ef602985f..054416ad7ba36 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js @@ -372,6 +372,7 @@ export class FileDataVisualizerView extends Component { hideBottomBar={this.hideBottomBar} savedObjectsClient={this.savedObjectsClient} fileUpload={this.props.fileUpload} + resultsLinks={this.props.resultsLinks} /> {bottomBarVisible && ( diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js index 232a32c75dc29..7e3c6d0c65d3e 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js @@ -591,6 +591,7 @@ export class ImportView extends Component { timeFieldName={timeFieldName} createIndexPattern={createIndexPattern} showFilebeatFlyout={this.showFilebeatFlyout} + additionalLinks={this.props.resultsLinks ?? []} /> {isFilebeatFlyoutVisible && ( diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx index b3f7e8531ebf5..3644f7053f1e8 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx @@ -11,9 +11,14 @@ import { getCoreStart, getPluginsStart } from '../../kibana_services'; // @ts-ignore import { FileDataVisualizerView } from './components/file_data_visualizer_view/index'; +import { ResultLink } from '../common/components/results_links'; + +interface Props { + additionalLinks?: ResultLink[]; +} export type FileDataVisualizerSpec = typeof FileDataVisualizer; -export const FileDataVisualizer: FC = () => { +export const FileDataVisualizer: FC<Props> = ({ additionalLinks }) => { const coreStart = getCoreStart(); const { data, maps, embeddable, share, security, fileUpload } = getPluginsStart(); const services = { @@ -33,6 +38,7 @@ export const FileDataVisualizer: FC = () => { savedObjectsClient={coreStart.savedObjects.client} http={coreStart.http} fileUpload={fileUpload} + resultsLinks={additionalLinks} /> </KibanaContextProvider> ); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx index 4b208b0a59ef1..48410aff54577 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx @@ -18,18 +18,26 @@ import type { IndexPattern } from '../../../../../../../../src/plugins/data/comm import { useDataVisualizerKibana } from '../../../kibana_context'; import { useUrlState } from '../../../common/util/url_state'; import { LinkCard } from '../../../common/components/link_card'; +import { ResultLink } from '../../../common/components/results_links'; interface Props { indexPattern: IndexPattern; searchString?: string | { [key: string]: any }; searchQueryLanguage?: string; + additionalLinks: ResultLink[]; } -// @todo: Add back create job card in a follow up PR -export const ActionsPanel: FC<Props> = ({ indexPattern, searchString, searchQueryLanguage }) => { +export const ActionsPanel: FC<Props> = ({ + indexPattern, + searchString, + searchQueryLanguage, + additionalLinks, +}) => { const [globalState] = useUrlState('_g'); const [discoverLink, setDiscoverLink] = useState(''); + const [generatedLinks, setGeneratedLinks] = useState<Record<string, string>>({}); + const { services: { application: { capabilities }, @@ -76,17 +84,56 @@ export const ActionsPanel: FC<Props> = ({ indexPattern, searchString, searchQuer } }; + Promise.all( + additionalLinks.map(async ({ canDisplay, getUrl }) => { + if ((await canDisplay({ indexPatternId })) === false) { + return null; + } + return getUrl({ globalState, indexPatternId }); + }) + ).then((urls) => { + const linksById = urls.reduce((acc, url, i) => { + if (url !== null) { + acc[additionalLinks[i].id] = url; + } + return acc; + }, {} as Record<string, string>); + setGeneratedLinks(linksById); + }); + getDiscoverUrl(); return () => { unmounted = true; }; - }, [indexPattern, searchString, searchQueryLanguage, globalState, capabilities, getUrlGenerator]); + }, [ + indexPattern, + searchString, + searchQueryLanguage, + globalState, + capabilities, + getUrlGenerator, + additionalLinks, + ]); // Note we use display:none for the DataRecognizer section as it needs to be // passed the recognizerResults object, and then run the recognizer check which // controls whether the recognizer section is ultimately displayed. return ( <div data-test-subj="dataVisualizerActionsPanel"> + {additionalLinks + .filter(({ id }) => generatedLinks[id] !== undefined) + .map((link) => ( + <> + <LinkCard + href={generatedLinks[link.id]} + icon={link.icon} + description={link.description} + title={link.title} + data-test-subj={link.dataTestSubj} + /> + <EuiSpacer size="m" /> + </> + ))} {discoverLink && ( <> <EuiTitle size="s"> diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index b116b25670ad2..c9ae3cf7f69a7 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -67,6 +67,7 @@ import { HelpMenu } from '../../../common/components/help_menu'; import { TimeBuckets } from '../../services/time_buckets'; import { extractSearchData } from '../../utils/saved_search_utils'; import { DataVisualizerIndexPatternManagement } from '../index_pattern_management'; +import { ResultLink } from '../../../common/components/results_links'; interface DataVisualizerPageState { overallStats: OverallStats; @@ -120,6 +121,7 @@ export const getDefaultDataVisualizerListState = (): Required<DataVisualizerInde export interface IndexDataVisualizerViewProps { currentIndexPattern: IndexPattern; currentSavedSearch: SavedSearchSavedObject | null; + additionalLinks?: ResultLink[]; } const restorableDefaults = getDefaultDataVisualizerListState(); @@ -138,7 +140,7 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi dataVisualizerProps.currentSavedSearch ); - const { currentIndexPattern } = dataVisualizerProps; + const { currentIndexPattern, additionalLinks } = dataVisualizerProps; useEffect(() => { if (dataVisualizerProps?.currentSavedSearch !== undefined) { @@ -886,6 +888,7 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi indexPattern={currentIndexPattern} searchQueryLanguage={searchQueryLanguage} searchString={searchString} + additionalLinks={additionalLinks ?? []} /> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index f9e9aece48a06..8e0230a9bc6f9 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -30,15 +30,18 @@ import { } from '../common/util/url_state'; import { useDataVisualizerKibana } from '../kibana_context'; import { IndexPattern } from '../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { ResultLink } from '../common/components/results_links'; export type IndexDataVisualizerSpec = typeof IndexDataVisualizer; export interface DataVisualizerUrlStateContextProviderProps { IndexDataVisualizerComponent: FC<IndexDataVisualizerViewProps>; + additionalLinks: ResultLink[]; } export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateContextProviderProps> = ({ IndexDataVisualizerComponent, + additionalLinks, }) => { const { services: { @@ -168,6 +171,7 @@ export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateCon <IndexDataVisualizerComponent currentIndexPattern={currentIndexPattern} currentSavedSearch={currentSavedSearch} + additionalLinks={additionalLinks} /> ) : ( <div /> @@ -176,7 +180,7 @@ export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateCon ); }; -export const IndexDataVisualizer: FC = () => { +export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ additionalLinks }) => { const coreStart = getCoreStart(); const { data, @@ -204,6 +208,7 @@ export const IndexDataVisualizer: FC = () => { <KibanaContextProvider services={{ ...services }}> <DataVisualizerUrlStateContextProvider IndexDataVisualizerComponent={IndexDataVisualizerView} + additionalLinks={additionalLinks} /> </KibanaContextProvider> ); diff --git a/x-pack/plugins/data_visualizer/public/index.ts b/x-pack/plugins/data_visualizer/public/index.ts index b0a622dfe490b..1a045f144c015 100644 --- a/x-pack/plugins/data_visualizer/public/index.ts +++ b/x-pack/plugins/data_visualizer/public/index.ts @@ -13,4 +13,9 @@ export function plugin() { export { DataVisualizerPluginStart } from './plugin'; -export type { IndexDataVisualizerViewProps } from './application'; +export type { + FileDataVisualizerSpec, + IndexDataVisualizerSpec, + IndexDataVisualizerViewProps, +} from './application'; +export type { ResultLink } from './application/common/components/results_links'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx index 0e9eeda51f786..37fd0a20865f7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/file_datavisualizer.tsx @@ -5,22 +5,99 @@ * 2.0. */ -import React, { FC, Fragment, useState, useEffect } from 'react'; - +import React, { FC, Fragment, useState, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useTimefilter } from '../../contexts/kibana'; import { NavigationMenu } from '../../components/navigation_menu'; import { HelpMenu } from '../../components/help_menu'; -import { useMlKibana } from '../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator } from '../../contexts/kibana'; + +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { isFullLicense } from '../../license'; +import { mlNodesAvailable, getMlNodeCount } from '../../ml_nodes_check/check_ml_nodes'; +import { checkPermission } from '../../capabilities/check_capabilities'; +import type { ResultLink, FileDataVisualizerSpec } from '../../../../../data_visualizer/public'; + +interface GetUrlParams { + indexPatternId: string; + globalState: any; +} export const FileDataVisualizerPage: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, dataVisualizer }, + services: { + docLinks, + dataVisualizer, + data: { + indexPatterns: { get: getIndexPattern }, + }, + }, } = useMlKibana(); - const [FileDataVisualizer, setFileDataVisualizer] = useState<FC<{}> | null>(null); + const mlUrlGenerator = useMlUrlGenerator(); + getMlNodeCount(); + + const [FileDataVisualizer, setFileDataVisualizer] = useState<FileDataVisualizerSpec | null>(null); + + const links: ResultLink[] = useMemo( + () => [ + { + id: 'create_ml_job', + title: i18n.translate('xpack.ml.fileDatavisualizer.actionsPanel.anomalyDetectionTitle', { + defaultMessage: 'Create new ML job', + }), + description: '', + icon: 'machineLearningApp', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async ({ indexPatternId }) => { + try { + const { timeFieldName } = await getIndexPattern(indexPatternId); + return ( + isFullLicense() && + timeFieldName !== undefined && + checkPermission('canCreateJob') && + mlNodesAvailable() + ); + } catch (error) { + return false; + } + }, + }, + { + id: 'open_in_data_viz', + title: i18n.translate('xpack.ml.fileDatavisualizer.actionsPanel.dataframeTitle', { + defaultMessage: 'Open in Data Visualizer', + }), + description: '', + icon: 'dataVisualizer', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async () => true, + }, + ], + [] + ); useEffect(() => { if (dataVisualizer !== undefined) { + getMlNodeCount(); const { getFileDataVisualizerComponent } = dataVisualizer; getFileDataVisualizerComponent().then(setFileDataVisualizer); } @@ -29,7 +106,7 @@ export const FileDataVisualizerPage: FC = () => { return ( <Fragment> <NavigationMenu tabId="datavisualizer" /> - {FileDataVisualizer} + {FileDataVisualizer !== null && <FileDataVisualizer additionalLinks={links} />} <HelpMenu docLink={docLinks.links.ml.guide} /> </Fragment> ); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx index af803d32e5139..60b6c90370b61 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/index_data_visualizer.tsx @@ -5,20 +5,40 @@ * 2.0. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; -import { useMlKibana, useTimefilter } from '../../contexts/kibana'; +import React, { FC, Fragment, useEffect, useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana, useTimefilter, useMlUrlGenerator } from '../../contexts/kibana'; import { NavigationMenu } from '../../components/navigation_menu'; import { HelpMenu } from '../../components/help_menu'; -import type { IndexDataVisualizerViewProps } from '../../../../../data_visualizer/public'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { isFullLicense } from '../../license'; +import { mlNodesAvailable, getMlNodeCount } from '../../ml_nodes_check/check_ml_nodes'; +import { checkPermission } from '../../capabilities/check_capabilities'; + +import type { ResultLink, IndexDataVisualizerSpec } from '../../../../../data_visualizer/public'; + +interface GetUrlParams { + indexPatternId: string; + globalState: any; +} + export const IndexDataVisualizerPage: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, dataVisualizer }, + services: { + docLinks, + dataVisualizer, + data: { + indexPatterns: { get: getIndexPattern }, + }, + }, } = useMlKibana(); - const [ - IndexDataVisualizer, - setIndexDataVisualizer, - ] = useState<FC<IndexDataVisualizerViewProps> | null>(null); + const mlUrlGenerator = useMlUrlGenerator(); + getMlNodeCount(); + + const [IndexDataVisualizer, setIndexDataVisualizer] = useState<IndexDataVisualizerSpec | null>( + null + ); useEffect(() => { if (dataVisualizer !== undefined) { @@ -26,10 +46,84 @@ export const IndexDataVisualizerPage: FC = () => { getIndexDataVisualizerComponent().then(setIndexDataVisualizer); } }, []); + + const links: ResultLink[] = useMemo( + () => [ + { + id: 'create_ml_ad_job', + title: i18n.translate('xpack.ml.indexDatavisualizer.actionsPanel.anomalyDetectionTitle', { + defaultMessage: 'Advanced anomaly detection', + }), + description: i18n.translate( + 'xpack.ml.indexDatavisualizer.actionsPanel.anomalyDetectionDescription', + { + defaultMessage: + 'Create a job with the full range of options for more advanced use cases.', + } + ), + icon: 'createAdvancedJob', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async ({ indexPatternId }) => { + try { + const { timeFieldName } = await getIndexPattern(indexPatternId); + return ( + isFullLicense() && + timeFieldName !== undefined && + checkPermission('canCreateJob') && + mlNodesAvailable() + ); + } catch (error) { + return false; + } + }, + dataTestSubj: 'dataVisualizerCreateAdvancedJobCard', + }, + { + id: 'create_ml_dfa_job', + title: i18n.translate('xpack.ml.indexDatavisualizer.actionsPanel.dataframeTitle', { + defaultMessage: 'Data frame analytics', + }), + description: i18n.translate( + 'xpack.ml.indexDatavisualizer.actionsPanel.dataframeDescription', + { + defaultMessage: 'Create outlier detection, regression, or classification analytics.', + } + ), + icon: 'classificationJob', + type: 'file', + getUrl: async ({ indexPatternId, globalState }: GetUrlParams) => { + return await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, + pageState: { + index: indexPatternId, + globalState, + }, + }); + }, + canDisplay: async () => { + return ( + isFullLicense() && checkPermission('canCreateDataFrameAnalytics') && mlNodesAvailable() + ); + }, + dataTestSubj: 'dataVisualizerCreateDataFrameAnalyticsCard', + }, + ], + [] + ); + return IndexDataVisualizer ? ( <Fragment> <NavigationMenu tabId="datavisualizer" /> - {IndexDataVisualizer} + {IndexDataVisualizer !== null && <IndexDataVisualizer additionalLinks={links} />} <HelpMenu docLink={docLinks.links.ml.guide} /> </Fragment> ) : ( diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index 3867ed6f7dfea..dee5b5a5e31c0 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -273,6 +273,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('creates filebeat config'); await ml.dataVisualizerFileBased.selectCreateFilebeatConfig(); + + await ml.testExecution.logTestStep('closes filebeat config'); + await ml.dataVisualizerFileBased.closeCreateFilebeatConfig(); }); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index 93e3b67ca1565..54d7b6ac294d1 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -15,16 +15,21 @@ export default function ({ getService }: FtrProviderContext) { this.tags(['mlqa']); const indexPatternName = 'ft_farequote'; - // @TODO: Re-enable in follow up - // const advancedJobWizardDatafeedQuery = `{ - // "bool": { - // "must": [ - // { - // "match_all": {} - // } - // ] - // } - // }`; // Note query is not currently passed to the wizard + + const advancedJobWizardDatafeedQuery = JSON.stringify( + { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }, + null, + 2 + ); + // Note query is not currently passed to the wizard before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); @@ -48,20 +53,19 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobSourceSelection.selectSourceForIndexBasedDataVisualizer(indexPatternName); }); - // @TODO: Re-enable in follow up - // it('opens the advanced job wizard', async () => { - // await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); - // await ml.dataVisualizerIndexBased.assertActionsPanelExists(); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); - // - // // Note the search is not currently passed to the wizard, just the index. - // await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); - // await ml.dataVisualizerIndexBased.clickCreateAdvancedJobButton(); - // await ml.jobTypeSelection.assertAdvancedJobWizardOpen(); - // await ml.jobWizardAdvanced.assertDatafeedQueryEditorExists(); - // await ml.jobWizardAdvanced.assertDatafeedQueryEditorValue(advancedJobWizardDatafeedQuery); - // }); + it('opens the advanced job wizard', async () => { + await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); + await ml.dataVisualizerIndexBased.assertActionsPanelExists(); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); + + // Note the search is not currently passed to the wizard, just the index. + await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); + await ml.dataVisualizerIndexBased.clickCreateAdvancedJobButton(); + await ml.jobTypeSelection.assertAdvancedJobWizardOpen(); + await ml.jobWizardAdvanced.assertDatafeedQueryEditorExists(); + await ml.jobWizardAdvanced.assertDatafeedQueryEditorValue(advancedJobWizardDatafeedQuery); + }); }); describe('view in discover page action', function () { diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index 18f4f6a38a7b1..10b57de911a10 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -93,8 +93,8 @@ export default function ({ getService }: FtrProviderContext) { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // @TODO: Re-enable in follow up - // const ecExpectedModuleId = 'sample_data_ecommerce'; + + const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -365,11 +365,10 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); + await ml.testExecution.logTestStep('should display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); + await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index a53ed2fafe30c..920d82ed381c0 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -94,7 +94,6 @@ export default function ({ getService }: FtrProviderContext) { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -357,11 +356,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts index 291e5a8964553..783be207baf22 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts @@ -139,5 +139,10 @@ export function MachineLearningDataVisualizerFileBasedProvider( await testSubjects.click('fileDataVisFilebeatConfigLink'); await testSubjects.existOrFail('fileDataVisFilebeatConfigPanel'); }, + + async closeCreateFilebeatConfig() { + await testSubjects.click('fileBeatConfigFlyoutCloseButton'); + await testSubjects.missingOrFail('fileDataVisFilebeatConfigPanel'); + }, }; } diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index aff1402f5567e..901744719144c 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -25,7 +25,6 @@ export default function ({ getService }: FtrProviderContext) { describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -134,11 +133,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index 2e5216d722518..0f271719a0d0f 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -25,8 +25,6 @@ export default function ({ getService }: FtrProviderContext) { describe(`(${testUser.user})`, function () { const ecIndexPattern = 'ft_module_sample_ecommerce'; const ecExpectedTotalCount = '287'; - // @TODO: Re-enable in follow up - // const ecExpectedModuleId = 'sample_data_ecommerce'; const uploadFilePath = path.join( __dirname, @@ -135,11 +133,9 @@ export default function ({ getService }: FtrProviderContext) { } await ml.dataVisualizerIndexBased.assertViewInDiscoverCard(testUser.discoverAvailable); - // @TODO: Re-enable in follow up - // await ml.testExecution.logTestStep('should not display job cards'); - // await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); - // await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); - // await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); + await ml.testExecution.logTestStep('should not display job cards'); + await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { From f9c52277fbf027c1cbcf6efbc53abbcfa7644910 Mon Sep 17 00:00:00 2001 From: Lisa Cawley <lcawley@elastic.co> Date: Mon, 5 Jul 2021 10:10:47 -0700 Subject: [PATCH 13/48] [DOCS] Adds ML link to Kibana quickstart (#104018) --- docs/getting-started/quick-start-guide.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index 5e6a60f019bea..d9835b312f3ee 100644 --- a/docs/getting-started/quick-start-guide.asciidoc +++ b/docs/getting-started/quick-start-guide.asciidoc @@ -143,3 +143,6 @@ If you are you ready to add your own data, refer to <<connect-to-elasticsearch,A If you want to ingest your data, refer to {fleet-guide}/fleet-quick-start.html[Quick start: Get logs and metrics into the Elastic Stack]. If you want to secure access to your data, refer to our guide on <<tutorial-secure-access-to-kibana, securing {kib}>> + +If you want to try out {ml-features} with the sample data sets, refer to +{ml-docs}/ml-getting-started.html[Getting started with {ml}]. \ No newline at end of file From b0b0584b55b7b48057d806436ba7fe69da7f1567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 5 Jul 2021 16:15:50 -0400 Subject: [PATCH 14/48] [APM] When using search bar UI should fall back to transactions (#103987) * adding kuery bar to search transactions metrics * addressig PR comments * fixing api test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/aggregated_transactions/index.ts | 10 +++- x-pack/plugins/apm/server/routes/services.ts | 35 +++++++----- x-pack/plugins/apm/server/routes/traces.ts | 7 ++- .../plugins/apm/server/routes/transactions.ts | 55 ++++++++++--------- .../tests/services/top_services.ts | 7 --- 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts index 8bfb137c1689c..60ce36a85235e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts @@ -6,7 +6,7 @@ */ import { SearchAggregatedTransactionSetting } from '../../../../common/aggregated_transactions'; -import { rangeQuery } from '../../../../server/utils/queries'; +import { kqlQuery, rangeQuery } from '../../../../server/utils/queries'; import { ProcessorEvent } from '../../../../common/processor_event'; import { TRANSACTION_DURATION, @@ -19,10 +19,12 @@ export async function getHasAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }) { const response = await apmEventClient.search( 'get_has_aggregated_transactions', @@ -36,6 +38,7 @@ export async function getHasAggregatedTransactions({ filter: [ { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, ...(start && end ? rangeQuery(start, end) : []), + ...kqlQuery(kuery), ], }, }, @@ -56,19 +59,22 @@ export async function getSearchAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { config: APMConfig; start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }): Promise<boolean> { const searchAggregatedTransactions = config['xpack.apm.searchAggregatedTransactions']; if ( + kuery || searchAggregatedTransactions === SearchAggregatedTransactionSetting.auto ) { - return getHasAggregatedTransactions({ start, end, apmEventClient }); + return getHasAggregatedTransactions({ start, end, apmEventClient, kuery }); } return ( diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 4384d2be78ca0..3329119726bb5 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -51,9 +51,10 @@ const servicesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params, logger } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServices({ environment, @@ -405,9 +406,10 @@ const serviceThroughputRoute = createApmServerRoute({ comparisonStart, comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -477,9 +479,10 @@ const serviceInstancesMainStatisticsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -552,9 +555,10 @@ const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({ latencyAggregationType, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServiceInstancesDetailedStatisticsPeriods({ environment, @@ -593,9 +597,10 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({ const { serviceName, serviceNodeName } = resources.params.path; const { transactionType, environment, kuery } = resources.params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getServiceInstanceMetadataDetails({ searchAggregatedTransactions, diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 7fce04644f220..bed7252dd20fd 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -26,9 +26,10 @@ const tracesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { environment, kuery, type: 'top_traces', searchAggregatedTransactions }, diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index bcc554e552fc3..c20de31847e8a 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -56,9 +56,10 @@ const transactionGroupsRoute = createApmServerRoute({ const { serviceName } = params.path; const { environment, kuery, transactionType } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { @@ -95,16 +96,16 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ handler: async (resources) => { const { params } = resources; const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return getServiceTransactionGroups({ environment, kuery, @@ -140,11 +141,6 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, handler: async (resources) => { const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { params } = resources; const { @@ -161,6 +157,11 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return await getServiceTransactionGroupDetailedStatisticsPeriods({ environment, kuery, @@ -208,9 +209,10 @@ const transactionLatencyChartsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const options = { environment, @@ -276,9 +278,10 @@ const transactionThroughputChartsRoute = createApmServerRoute({ transactionName, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getThroughputCharts({ environment, @@ -327,9 +330,10 @@ const transactionChartsDistributionRoute = createApmServerRoute({ traceId = '', } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionDistribution({ environment, @@ -411,9 +415,10 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getErrorRatePeriods({ environment, diff --git a/x-pack/test/apm_api_integration/tests/services/top_services.ts b/x-pack/test/apm_api_integration/tests/services/top_services.ts index 37f7b09e8b7d2..9c687fc74acce 100644 --- a/x-pack/test/apm_api_integration/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/tests/services/top_services.ts @@ -286,13 +286,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(unfilteredServiceNames).to.eql(filteredServiceNames); - expect( - filteredResponse.body.items.every((item) => { - // make sure it did not query transaction data - return isEmpty(item.avgResponseTime); - }) - ).to.be(true); - expect(filteredResponse.body.items.every((item) => !!item.agentName)).to.be(true); }); } From e3c2dfc9b4261d5b251e3020287beb10e4301fad Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet <nicolas.chaulet@elastic.co> Date: Mon, 5 Jul 2021 17:14:23 -0400 Subject: [PATCH 15/48] [Fleet] Preserve search when syncing hash router and urls (#104310) --- x-pack/plugins/fleet/public/applications/fleet/app.tsx | 2 +- x-pack/plugins/fleet/public/applications/integrations/app.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 8be6232733def..5ac594842d392 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -221,7 +221,7 @@ export const FleetAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index cad51a54d7074..ae59d33e44b82 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -196,7 +196,7 @@ export const IntegrationsAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); From f5e7b46eb92f10582469863ae33bad5d32c4889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 5 Jul 2021 17:15:22 -0400 Subject: [PATCH 16/48] [APM] Refactoring menu section (#104338) --- .../instance_actions_menu/menu_sections.ts | 35 ++------- .../transaction_action_menu/sections.ts | 28 +------ .../sections_helper.test.ts | 76 +++++++++++++++++++ .../sections_helper.ts | 39 ++++++++++ 4 files changed, 123 insertions(+), 55 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts index 30995fbd13397..0e78e44eedf77 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts @@ -7,30 +7,17 @@ import { i18n } from '@kbn/i18n'; import { IBasePath } from 'kibana/public'; -import { isEmpty } from 'lodash'; import moment from 'moment'; import { APIReturnType } from '../../../../../services/rest/createCallApmApi'; import { getInfraHref } from '../../../../shared/Links/InfraLink'; +import { + Action, + getNonEmptySections, + SectionRecord, +} from '../../../../shared/transaction_action_menu/sections_helper'; type InstaceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; -interface Action { - key: string; - label: string; - href?: string; - onClick?: () => void; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record<string, Section[]>; - function getInfraMetricsQuery(timestamp?: string) { if (!timestamp) { return { from: 0, to: 0 }; @@ -189,15 +176,5 @@ export function getMenuSections({ apm: [{ key: 'apm', actions: apmActions }], }; - // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts index 0e30cfe3168f1..ebc48e1e9faf4 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts @@ -17,6 +17,7 @@ import { getDiscoverHref } from '../Links/DiscoverLinks/DiscoverLink'; import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; import { getInfraHref } from '../Links/InfraLink'; import { fromQuery } from '../Links/url_helpers'; +import { SectionRecord, getNonEmptySections, Action } from './sections_helper'; function getInfraMetricsQuery(transaction: Transaction) { const timestamp = new Date(transaction['@timestamp']).getTime(); @@ -28,22 +29,6 @@ function getInfraMetricsQuery(transaction: Transaction) { }; } -interface Action { - key: string; - label: string; - href: string; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record<string, Section[]>; - export const getSections = ({ transaction, basePath, @@ -296,14 +281,5 @@ export const getSections = ({ }; // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); }; diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts new file mode 100644 index 0000000000000..741a66d71be14 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getNonEmptySections } from './sections_helper'; + +describe('getNonEmptySections', () => { + it('returns empty when no section is available', () => { + expect(getNonEmptySections({})).toEqual([]); + }); + it("returns empty when section doesn't have actions", () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + }) + ).toEqual([]); + }); + + it('returns only sections with actions with condition true', () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + bar: [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + { + key: 'bar_action_2', + label: 'Bar Action 2', + condition: false, + }, + ], + }, + ], + }) + ).toEqual([ + [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + ], + }, + ], + ]); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts new file mode 100644 index 0000000000000..1632fdb678013 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; + +export interface Action { + key: string; + label: string; + href?: string; + onClick?: () => void; + condition: boolean; +} + +interface Section { + key: string; + title?: string; + subtitle?: string; + actions: Action[]; +} + +export type SectionRecord = Record<string, Section[]>; + +/** Filter out actions that shouldnt be shown and sections without any actions. */ +export function getNonEmptySections(sectionRecord: SectionRecord) { + return Object.values(sectionRecord) + .map((sections) => + sections + .map((section) => ({ + ...section, + actions: section.actions.filter((action) => action.condition), + })) + .filter((section) => !isEmpty(section.actions)) + ) + .filter((sections) => !isEmpty(sections)); +} From 2e492c2083b753302567213269dfe1810ed731cf Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 02:14:32 +0100 Subject: [PATCH 17/48] skip failing es promotion suite (#104362) --- x-pack/test/functional/apps/discover/async_scripted_fields.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index 2c18051405964..427d8c21635c4 100644 --- a/x-pack/test/functional/apps/discover/async_scripted_fields.js +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const security = getService('security'); - describe('async search with scripted fields', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104362 + describe.skip('async search with scripted fields', function () { this.tags(['skipFirefox']); before(async function () { From 16f69d24b6d59e6379c36d58702335bc31e6d33b Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 02:19:47 +0100 Subject: [PATCH 18/48] skip failing es promotion suite (#104364) --- test/functional/apps/context/_context_navigation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 7f72d44c50ea0..2efc145b12561 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -21,7 +21,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); const kibanaServer = getService('kibanaServer'); - describe('discover - context - back navigation', function contextSize() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104364 + describe.skip('discover - context - back navigation', function contextSize() { before(async function () { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); From 269577703a90bd6a3eab62f790ba7e23debf2525 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 02:25:34 +0100 Subject: [PATCH 19/48] skip failing es promotion suite (#104365) --- test/functional/apps/dashboard/saved_search_embeddable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index 5bcec338aad1e..33d015a4c6019 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'timePicker', 'discover']); - describe('dashboard saved search embeddable', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104365 + describe.skip('dashboard saved search embeddable', () => { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); From dfc5dbb31b2ed1c4fdce8f5cb60eaee120bc2e3f Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 02:29:47 +0100 Subject: [PATCH 20/48] skip failing es promotion suite (#104366) --- test/functional/apps/discover/_saved_queries.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 20f2cab907d9b..29073c5fe4ebb 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -40,7 +40,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('saved query management component functionality', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104366 + describe.skip('saved query management component functionality', function () { before(async function () { // set up a query with filters and a time filter log.debug('set up a query with filters to save'); From 7fefdb1d8977c94a685876784ce6987b58f0c94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= <istvan.szabo@elastic.co> Date: Tue, 6 Jul 2021 11:47:57 +0200 Subject: [PATCH 21/48] [DOCS] Changes docs link service link for ROC curve. (#104380) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/core/public/doc_links/doc_links_service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6bb714e913838..f215c86d9d507 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -249,7 +249,7 @@ export class DocLinksService { customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, - outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-roc`, + outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-finding-outliers.html#ml-dfanalytics-roc`, regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`, classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-class-aucroc`, }, From 253a398db46b273945ac9bdfe4c63f5154fd2709 Mon Sep 17 00:00:00 2001 From: Dima Arnautov <dmitrii.arnautov@elastic.co> Date: Tue, 6 Jul 2021 12:09:16 +0200 Subject: [PATCH 22/48] [ML] Fix embeddable swim lane container to show a scrollbar on overflow (#104289) * [ML] fix legend * [ML] add extra div wrapper for overflow scroll --- .../explorer/swimlane_container.tsx | 138 +++++++++--------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index d959328218a18..82f8a90fafb7d 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -40,7 +40,6 @@ import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { mlEscape } from '../util/string_utils'; import { FormattedTooltip, MlTooltipComponent } from '../components/chart_tooltip/chart_tooltip'; import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; -import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; @@ -62,6 +61,9 @@ declare global { } } +function getFormattedSeverityScore(score: number): string { + return String(parseInt(String(score), 10)); +} /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -122,7 +124,7 @@ const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { defaultMessage: 'Max anomaly score', }), - value: cell.formattedValue, + value: cell.formattedValue === '0' ? ' < 1' : cell.formattedValue, color: cell.color, // @ts-ignore seriesIdentifier: { @@ -408,73 +410,75 @@ export const SwimlaneContainer: FC<SwimlaneProps> = ({ grow={false} > <> - <div style={{ height: `${containerHeight}px`, position: 'relative' }}> - {showSwimlane && !isLoading && ( - <Chart className={'mlSwimLaneContainer'}> - <Settings - onElementClick={onElementClick} - showLegend={showLegend} - legendPosition={Position.Top} - xDomain={xDomain} - tooltip={tooltipOptions} - debugState={window._echDebugStateFlag ?? false} - /> - - <Heatmap - id={id} - colorScale={ScaleType.Threshold} - ranges={[ - ANOMALY_THRESHOLD.LOW, - ANOMALY_THRESHOLD.WARNING, - ANOMALY_THRESHOLD.MINOR, - ANOMALY_THRESHOLD.MAJOR, - ANOMALY_THRESHOLD.CRITICAL, - ]} - colors={[ - SEVERITY_COLORS.BLANK, - SEVERITY_COLORS.LOW, - SEVERITY_COLORS.WARNING, - SEVERITY_COLORS.MINOR, - SEVERITY_COLORS.MAJOR, - SEVERITY_COLORS.CRITICAL, - ]} - data={swimLanePoints} - xAccessor="time" - yAccessor="laneLabel" - valueAccessor="value" - highlightedData={highlightedData} - valueFormatter={getFormattedSeverityScore} - xScaleType={ScaleType.Time} - ySortPredicate="dataIndex" - config={swimLaneConfig} - /> - </Chart> - )} + <div> + <div style={{ height: `${containerHeight}px` }}> + {showSwimlane && !isLoading && ( + <Chart className={'mlSwimLaneContainer'}> + <Settings + onElementClick={onElementClick} + showLegend={showLegend} + legendPosition={Position.Top} + xDomain={xDomain} + tooltip={tooltipOptions} + debugState={window._echDebugStateFlag ?? false} + /> - {isLoading && ( - <EuiText - textAlign={'center'} - style={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%,-50%)', - }} - > - <EuiLoadingChart - size="xl" - mono={true} - data-test-subj="mlSwimLaneLoadingIndicator" + <Heatmap + id={id} + colorScale={ScaleType.Threshold} + ranges={[ + ANOMALY_THRESHOLD.LOW, + ANOMALY_THRESHOLD.WARNING, + ANOMALY_THRESHOLD.MINOR, + ANOMALY_THRESHOLD.MAJOR, + ANOMALY_THRESHOLD.CRITICAL, + ]} + colors={[ + SEVERITY_COLORS.BLANK, + SEVERITY_COLORS.LOW, + SEVERITY_COLORS.WARNING, + SEVERITY_COLORS.MINOR, + SEVERITY_COLORS.MAJOR, + SEVERITY_COLORS.CRITICAL, + ]} + data={swimLanePoints} + xAccessor="time" + yAccessor="laneLabel" + valueAccessor="value" + highlightedData={highlightedData} + valueFormatter={getFormattedSeverityScore} + xScaleType={ScaleType.Time} + ySortPredicate="dataIndex" + config={swimLaneConfig} + /> + </Chart> + )} + + {isLoading && ( + <EuiText + textAlign={'center'} + style={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%,-50%)', + }} + > + <EuiLoadingChart + size="xl" + mono={true} + data-test-subj="mlSwimLaneLoadingIndicator" + /> + </EuiText> + )} + {!isLoading && !showSwimlane && ( + <EuiEmptyPrompt + titleSize="xxs" + style={{ padding: 0 }} + title={<h2>{noDataWarning}</h2>} /> - </EuiText> - )} - {!isLoading && !showSwimlane && ( - <EuiEmptyPrompt - titleSize="xxs" - style={{ padding: 0 }} - title={<h2>{noDataWarning}</h2>} - /> - )} + )} + </div> </div> {swimlaneType === SWIMLANE_TYPE.OVERALL && showSwimlane && From 98942050c98c6efdceb14a8b87c64a63d4a239cb Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 11:41:41 +0100 Subject: [PATCH 23/48] skip flaky suite (#104372) --- x-pack/test/functional/apps/discover/reporting.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index 3eb66204df564..0b018b4428e1d 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -73,7 +73,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Generate CSV: new search', () => { + // FLAKY: https://github.com/elastic/kibana/issues/104372 + describe.skip('Generate CSV: new search', () => { beforeEach(async () => { await kibanaServer.importExport.load(ecommerceSOPath); await PageObjects.common.navigateToApp('discover'); From 79608dcc9eee104372377e335fab5f10b0da6c88 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 11:58:42 +0100 Subject: [PATCH 24/48] skip failing es promotion suite (#104409) --- test/functional/apps/discover/_discover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index bb75b4441f880..245b895d75b3a 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -38,7 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('query', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104409 + describe.skip('query', function () { const queryName1 = 'Query # 1'; it('should show correct time range string by timepicker', async function () { From ec5d3988654ab58ce3e2d4a23ee7f59530ba43bc Mon Sep 17 00:00:00 2001 From: Vadim Dalecky <streamich@gmail.com> Date: Tue, 6 Jul 2021 13:01:51 +0200 Subject: [PATCH 25/48] Redirect endpoint (#103899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add redirect endpoint app * feat: 🎸 improve spinner design * feat: 🎸 implement basic version of redirect endpoint * feat: 🎸 render errors for user on the screen * feat: 🎸 improve error message display * feat: 🎸 improve error display * feat: 🎸 improve locator errors * feat: 🎸 improve errors * feat: 🎸 improve persistable state types * feat: 🎸 implement migrateToLatest function * feat: 🎸 migrate locator params to the latest in redirect endp * Update src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts * refactor: 💡 make Versioned state be an object * fix: 🐛 use new VersionedState interface in redirect endpoint * refactor: 💡 move parseSearchParams into a separate function * feat: 🎸 implement redirect URL formatter * feat: 🎸 export redirect URL parsing and formatting functions * refactor: 💡 use relative import * test: 💍 add example links through redirect endpoint * test: 💍 use updated VersionedState type * test: 💍 add redirect manager tests * feat: 🎸 add redirect endpoint app to common schema * chore: 🤖 update telemetry schema Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- examples/locator_explorer/public/app.tsx | 21 +- .../collectors/application_usage/schema.ts | 1 + .../common/persistable_state/index.ts | 86 +-------- .../migrate_to_latest.test.ts | 152 +++++++++++++++ .../persistable_state/migrate_to_latest.ts | 30 +++ .../common/persistable_state/types.ts | 180 ++++++++++++++++++ src/plugins/share/common/mocks.ts | 9 + .../common/url_service/locators/locator.ts | 2 +- src/plugins/share/common/url_service/mocks.ts | 37 ++++ src/plugins/share/public/index.ts | 1 + src/plugins/share/public/mocks.ts | 9 + src/plugins/share/public/plugin.ts | 6 + src/plugins/share/public/url_service/index.ts | 9 + .../public/url_service/redirect/README.md | 18 ++ .../url_service/redirect/components/error.tsx | 53 ++++++ .../url_service/redirect/components/page.tsx | 46 +++++ .../redirect/components/spinner.tsx | 35 ++++ .../public/url_service/redirect/index.ts | 11 ++ .../redirect/redirect_manager.test.ts | 92 +++++++++ .../url_service/redirect/redirect_manager.ts | 95 +++++++++ .../public/url_service/redirect/render.ts | 19 ++ .../util/format_search_params.test.ts | 43 +++++ .../redirect/util/format_search_params.ts | 19 ++ .../redirect/util/parse_search_params.test.ts | 65 +++++++ .../redirect/util/parse_search_params.ts | 84 ++++++++ src/plugins/telemetry/schema/oss_plugins.json | 131 +++++++++++++ 26 files changed, 1168 insertions(+), 86 deletions(-) create mode 100644 src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts create mode 100644 src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts create mode 100644 src/plugins/kibana_utils/common/persistable_state/types.ts create mode 100644 src/plugins/share/common/mocks.ts create mode 100644 src/plugins/share/common/url_service/mocks.ts create mode 100644 src/plugins/share/public/mocks.ts create mode 100644 src/plugins/share/public/url_service/index.ts create mode 100644 src/plugins/share/public/url_service/redirect/README.md create mode 100644 src/plugins/share/public/url_service/redirect/components/error.tsx create mode 100644 src/plugins/share/public/url_service/redirect/components/page.tsx create mode 100644 src/plugins/share/public/url_service/redirect/components/spinner.tsx create mode 100644 src/plugins/share/public/url_service/redirect/index.ts create mode 100644 src/plugins/share/public/url_service/redirect/redirect_manager.test.ts create mode 100644 src/plugins/share/public/url_service/redirect/redirect_manager.ts create mode 100644 src/plugins/share/public/url_service/redirect/render.ts create mode 100644 src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts create mode 100644 src/plugins/share/public/url_service/redirect/util/format_search_params.ts create mode 100644 src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts create mode 100644 src/plugins/share/public/url_service/redirect/util/parse_search_params.ts diff --git a/examples/locator_explorer/public/app.tsx b/examples/locator_explorer/public/app.tsx index 440e16302dff9..8e38c097a847e 100644 --- a/examples/locator_explorer/public/app.tsx +++ b/examples/locator_explorer/public/app.tsx @@ -19,7 +19,7 @@ import { EuiFieldText } from '@elastic/eui'; import { EuiPageHeader } from '@elastic/eui'; import { EuiLink } from '@elastic/eui'; import { AppMountParameters } from '../../../src/core/public'; -import { SharePluginSetup } from '../../../src/plugins/share/public'; +import { formatSearchParams, SharePluginSetup } from '../../../src/plugins/share/public'; import { HelloLocatorV1Params, HelloLocatorV2Params, @@ -34,6 +34,7 @@ interface MigratedLink { linkText: string; link: string; version: string; + params: HelloLocatorV1Params | HelloLocatorV2Params; } const ActionsExplorer = ({ share }: Props) => { @@ -93,6 +94,7 @@ const ActionsExplorer = ({ share }: Props) => { linkText: savedLink.linkText, link, version: savedLink.version, + params: savedLink.params, } as MigratedLink; }) ); @@ -157,7 +159,24 @@ const ActionsExplorer = ({ share }: Props) => { target="_blank" > {link.linkText} + </EuiLink>{' '} + ( + <EuiLink + color={link.version !== '0.0.2' ? 'danger' : 'primary'} + data-test-subj="linkToHelloPage" + href={ + '/app/r?' + + formatSearchParams({ + id: 'HELLO_LOCATOR', + version: link.version, + params: link.params, + }).toString() + } + target="_blank" + > + through redirect app </EuiLink> + ) <br /> </React.Fragment> )) diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 65857f02c883d..54a3fe9e4399c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -129,6 +129,7 @@ export const applicationUsageSchema = { error: commonSchema, status: commonSchema, kibanaOverview: commonSchema, + r: commonSchema, // X-Pack apm: commonSchema, diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 809cb15c3e960..18f59186f6183 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -6,87 +6,5 @@ * Side Public License, v 1. */ -import { SavedObjectReference } from '../../../../core/types'; - -export type SerializableValue = string | number | boolean | null | undefined | SerializableState; -export type Serializable = SerializableValue | SerializableValue[]; - -export type SerializableState = { - [key: string]: Serializable; -}; - -export type MigrateFunction< - FromVersion extends SerializableState = SerializableState, - ToVersion extends SerializableState = SerializableState -> = (state: FromVersion) => ToVersion; - -export type MigrateFunctionsObject = { - [key: string]: MigrateFunction; -}; - -export interface PersistableStateService<P extends SerializableState = SerializableState> { - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record<string, any>) => Record<string, any>; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * migrateToLatest function receives state of older version and should migrate to the latest version - * @param state - * @param version - */ - migrateToLatest?: (state: SerializableState, version: string) => P; - - /** - * migrate function runs the specified migration - * @param state - * @param version - */ - migrate: (state: SerializableState, version: string) => SerializableState; -} - -export interface PersistableState<P extends SerializableState = SerializableState> { - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record<string, any>) => Record<string, any>; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * list of all migrations per semver - */ - migrations: MigrateFunctionsObject; -} - -export type PersistableStateDefinition<P extends SerializableState = SerializableState> = Partial< - PersistableState<P> ->; +export * from './types'; +export { migrateToLatest } from './migrate_to_latest'; diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts new file mode 100644 index 0000000000000..2ae376e787d2f --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableState, MigrateFunction } from './types'; +import { migrateToLatest } from './migrate_to_latest'; + +interface StateV1 extends SerializableState { + name: string; +} + +interface StateV2 extends SerializableState { + firstName: string; + lastName: string; +} + +interface StateV3 extends SerializableState { + firstName: string; + lastName: string; + isAdmin: boolean; + age: number; +} + +const migrationV2: MigrateFunction<StateV1, StateV2> = ({ name }) => { + return { + firstName: name, + lastName: '', + }; +}; + +const migrationV3: MigrateFunction<StateV2, StateV3> = ({ firstName, lastName }) => { + return { + firstName, + lastName, + isAdmin: false, + age: 0, + }; +}; + +test('returns the same object if there are no migrations to be applied', () => { + const migrated = migrateToLatest( + {}, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(migrated).toEqual({ + state: { name: 'Foo' }, + version: '0.0.1', + }); +}); + +test('applies a single migration', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + }); + expect(newVersion).toEqual('0.0.2'); +}); + +test('does not apply migration if it has the same version as state', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.54': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.54', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.0.54'); +}); + +test('does not apply migration if it has lower version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.2.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.3.1', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.3.1'); +}); + +test('applies two migrations consecutively', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '7.13.4', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); + +test('applies only migrations which are have higher semver version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, // not applied + '7.14.1': (() => ({})) as MigrateFunction, // not applied + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { firstName: 'FooBar', lastName: 'Baz' }, + version: '7.14.1', + } + ); + + expect(newState).toEqual({ + firstName: 'FooBar', + lastName: 'Baz', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts new file mode 100644 index 0000000000000..c16392164e3e4 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { compare } from 'semver'; +import { SerializableState, VersionedState, MigrateFunctionsObject } from './types'; + +export function migrateToLatest<S extends SerializableState>( + migrations: MigrateFunctionsObject, + { state, version: oldVersion }: VersionedState +): VersionedState<S> { + const versions = Object.keys(migrations || {}) + .filter((v) => compare(v, oldVersion) > 0) + .sort(compare); + + if (!versions.length) return { state, version: oldVersion } as VersionedState<S>; + + for (const version of versions) { + state = migrations[version]!(state); + } + + return { + state: state as S, + version: versions[versions.length - 1], + }; +} diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts new file mode 100644 index 0000000000000..f7168b46e7fca --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectReference } from '../../../../core/types'; + +/** + * Serializable state is something is a POJO JavaScript object that can be + * serialized to a JSON string. + */ +export type SerializableState = { + [key: string]: Serializable; +}; +export type SerializableValue = string | number | boolean | null | undefined | SerializableState; +export type Serializable = SerializableValue | SerializableValue[]; + +/** + * Versioned state is a POJO JavaScript object that can be serialized to JSON, + * and which also contains the version information. The version is stored in + * semver format and corresponds to the Kibana release version when the object + * was created. The version can be used to apply migrations to the object. + * + * For example: + * + * ```ts + * const obj: VersionedState<{ dashboardId: string }> = { + * version: '7.14.0', + * state: { + * dashboardId: '123', + * }, + * }; + * ``` + */ +export interface VersionedState<S extends SerializableState = SerializableState> { + version: string; + state: S; +} + +/** + * Persistable state interface can be implemented by something that persists + * (stores) state, for example, in a saved object. Once implemented that thing + * will gain ability to "extract" and "inject" saved object references, which + * are necessary for various saved object tasks, such as export. It will also be + * able to do state migrations across Kibana versions, if the shape of the state + * would change over time. + * + * @todo Maybe rename it to `PersistableStateItem`? + */ +export interface PersistableState<P extends SerializableState = SerializableState> { + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry: (state: P, stats: Record<string, any>) => Record<string, any>; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject: (state: P, references: SavedObjectReference[]) => P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract: (state: P) => { state: P; references: SavedObjectReference[] }; + + /** + * A list of migration functions, which migrate the persistable state + * serializable object to the next version. Migration functions should are + * keyed by the Kibana version using semver, where the version indicates to + * which version the state will be migrated to. + */ + migrations: MigrateFunctionsObject; +} + +/** + * Collection of migrations that a given type of persistable state object has + * accumulated over time. Migration functions are keyed using semver version + * of Kibana releases. + */ +export type MigrateFunctionsObject = { [semver: string]: MigrateFunction }; +export type MigrateFunction< + FromVersion extends SerializableState = SerializableState, + ToVersion extends SerializableState = SerializableState +> = (state: FromVersion) => ToVersion; + +/** + * @todo Shall we remove this? + */ +export type PersistableStateDefinition<P extends SerializableState = SerializableState> = Partial< + PersistableState<P> +>; + +/** + * @todo Add description. + */ +export interface PersistableStateService<P extends SerializableState = SerializableState> { + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry(state: P, collector: Record<string, any>): Record<string, any>; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject(state: P, references: SavedObjectReference[]): P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract(state: P): { state: P; references: SavedObjectReference[] }; + + /** + * Migrate function runs a specified migration of a {@link PersistableState} + * item. + * + * When using this method it is up to consumer to make sure that the + * migration function are executed in the right semver order. To avoid such + * potentially error prone complexity, prefer using `migrateToLatest` method + * instead. + * + * @param state The old persistable state serializable state object, which + * needs a migration. + * @param version Semver version of the migration to execute. + * @returns Persistable state object updated with the specified migration + * applied to it. + */ + migrate(state: SerializableState, version: string): SerializableState; + + /** + * A function which receives the state of an older object and version and + * should migrate the state of the object to the latest possible version using + * the `.migrations` dictionary provided on a {@link PersistableState} item. + * + * @param state The persistable state serializable state object. + * @param version Current semver version of the `state`. + * @returns A serializable state object migrated to the latest state. + */ + migrateToLatest?: (state: VersionedState) => VersionedState<P>; +} diff --git a/src/plugins/share/common/mocks.ts b/src/plugins/share/common/mocks.ts new file mode 100644 index 0000000000000..6768c1aff810a --- /dev/null +++ b/src/plugins/share/common/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_service/mocks'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 680fb2231fc48..bae57b6d8a31d 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -30,7 +30,7 @@ export interface LocatorDependencies { getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise<string>; } -export class Locator<P extends SerializableState> implements PersistableState<P>, LocatorPublic<P> { +export class Locator<P extends SerializableState> implements LocatorPublic<P> { public readonly migrations: PersistableState<P>['migrations']; constructor( diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts new file mode 100644 index 0000000000000..be86cfe401713 --- /dev/null +++ b/src/plugins/share/common/url_service/mocks.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import type { LocatorDefinition, KibanaLocation } from '.'; +import { UrlService } from '.'; + +export class MockUrlService extends UrlService { + constructor() { + super({ + navigate: async () => {}, + getUrl: async ({ app, path }, { absolute }) => { + return `${absolute ? 'https://example.com' : ''}/app/${app}${path}`; + }, + }); + } +} + +export class MockLocatorDefinition implements LocatorDefinition<any> { + constructor(public readonly id: string) {} + + public readonly getLocation = async (): Promise<KibanaLocation> => { + return { + app: 'test', + path: '/test', + state: { + foo: 'bar', + }, + }; + }; +} diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 5ee3156534c5e..1f999b59ddb61 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -9,6 +9,7 @@ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; +export { parseSearchParams, formatSearchParams } from './url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts new file mode 100644 index 0000000000000..eb9c6d0d10906 --- /dev/null +++ b/src/plugins/share/public/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../common/mocks'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 893108b56bcfa..adc28556d7a3c 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -19,6 +19,7 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; import { UrlService } from '../common/url_service'; +import { RedirectManager } from './url_service'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -86,6 +87,11 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> { }, }); + const redirectManager = new RedirectManager({ + url: this.url, + }); + redirectManager.registerRedirectApp(core); + return { ...this.shareMenuRegistry.setup(), urlGenerators: this.urlGeneratorsService.setup(core), diff --git a/src/plugins/share/public/url_service/index.ts b/src/plugins/share/public/url_service/index.ts new file mode 100644 index 0000000000000..8fa88e9c570bd --- /dev/null +++ b/src/plugins/share/public/url_service/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect'; diff --git a/src/plugins/share/public/url_service/redirect/README.md b/src/plugins/share/public/url_service/redirect/README.md new file mode 100644 index 0000000000000..cd31f2b80099b --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/README.md @@ -0,0 +1,18 @@ +# Redirect endpoint + +This folder contains implementation of *the Redirect Endpoint*. The Redirect +Endpoint receives parameters of a locator and then "redirects" the user using +navigation without page refresh to the location targeted by the locator. While +using the locator, it is also possible to set the *location state* of the +target page. Location state is a serializable object which can be passed to +the destination app while navigating without a page reload. + +``` +/app/r?l=MY_LOCATOR&v=7.14.0&p=(dashboardId:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +``` + +For example: + +``` +/app/r?l=DISCOVER_APP_LOCATOR&v=7.14.0&p={%22indexPatternId%22:%22d3d7af60-4c81-11e8-b3d7-01146121b73d%22} +``` diff --git a/src/plugins/share/public/url_service/redirect/components/error.tsx b/src/plugins/share/public/url_service/redirect/components/error.tsx new file mode 100644 index 0000000000000..716848427c638 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/error.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { + EuiEmptyPrompt, + EuiCallOut, + EuiCodeBlock, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultTitle = i18n.translate('share.urlService.redirect.components.Error.title', { + defaultMessage: 'Redirection error', + description: + 'Title displayed to user in redirect endpoint when redirection cannot be performed successfully.', +}); + +export interface ErrorProps { + title?: string; + error: Error; +} + +export const Error: React.FC<ErrorProps> = ({ title = defaultTitle, error }) => { + return ( + <EuiEmptyPrompt + iconType={'alert'} + iconColor={'danger'} + title={<h2>{title}</h2>} + body={ + <EuiCallOut color="danger"> + <EuiFlexGroup justifyContent="spaceAround"> + <EuiFlexItem> + <EuiText color="danger">{error.message}</EuiText> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size={'l'} /> + <EuiCodeBlock language="bash" className="eui-textBreakAll" isCopyable> + {error.stack ? error.stack : ''} + </EuiCodeBlock> + </EuiCallOut> + } + /> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/page.tsx b/src/plugins/share/public/url_service/redirect/components/page.tsx new file mode 100644 index 0000000000000..805213b73fdd0 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/page.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiPageTemplate } from '@elastic/eui'; +import { Error } from './error'; +import { RedirectManager } from '../redirect_manager'; +import { Spinner } from './spinner'; + +export interface PageProps { + manager: Pick<RedirectManager, 'error$'>; +} + +export const Page: React.FC<PageProps> = ({ manager }) => { + const error = useObservable(manager.error$); + + if (error) { + return ( + <EuiPageTemplate + template="centeredContent" + pageContentProps={{ + color: 'danger', + }} + > + <Error error={error} /> + </EuiPageTemplate> + ); + } + + return ( + <EuiPageTemplate + template="centeredContent" + pageContentProps={{ + color: 'primary', + }} + > + <Spinner /> + </EuiPageTemplate> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/spinner.tsx b/src/plugins/share/public/url_service/redirect/components/spinner.tsx new file mode 100644 index 0000000000000..a70ae5eb096af --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/spinner.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const text = i18n.translate('share.urlService.redirect.components.Spinner.label', { + defaultMessage: 'Redirecting…', + description: 'Redirect endpoint spinner label.', +}); + +export const Spinner: React.FC = () => { + return ( + <EuiFlexGroup justifyContent="spaceAround" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiFlexGroup justifyContent="spaceAround" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiLoadingElastic size="xxl" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiText color="subdued" size={'m'}> + {text} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/index.ts b/src/plugins/share/public/url_service/redirect/index.ts new file mode 100644 index 0000000000000..8dbc5f4e0ab1c --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect_manager'; +export { formatSearchParams } from './util/format_search_params'; +export { parseSearchParams } from './util/parse_search_params'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts new file mode 100644 index 0000000000000..f610268f529bc --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectManager } from './redirect_manager'; +import { MockUrlService } from '../../mocks'; +import { MigrateFunction } from 'src/plugins/kibana_utils/common'; + +const setup = () => { + const url = new MockUrlService(); + const locator = url.locators.create({ + id: 'TEST_LOCATOR', + getLocation: async () => { + return { + app: '', + path: '', + state: {}, + }; + }, + migrations: { + '0.0.2': ((({ num }: { num: number }) => ({ num: num * 2 })) as unknown) as MigrateFunction, + }, + }); + const manager = new RedirectManager({ + url, + }); + + return { + url, + locator, + manager, + }; +}; + +describe('on page mount', () => { + test('execute locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + expect(spy).toHaveBeenCalledTimes(0); + manager.onMount(`l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}`); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('passes arguments provided in URL to locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent( + JSON.stringify({ + foo: 'bar', + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + foo: 'bar', + }); + }); + + test('migrates parameters on-the-fly to the latest version', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.1&p=${encodeURIComponent( + JSON.stringify({ + num: 1, + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + num: 2, + }); + }); + + test('throws if locator does not exist', async () => { + const { manager } = setup(); + + expect(() => + manager.onMount( + `l=TEST_LOCATOR_WHICH_DOES_NOT_EXIST&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}` + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator [ID = TEST_LOCATOR_WHICH_DOES_NOT_EXIST] does not exist."` + ); + }); +}); diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts new file mode 100644 index 0000000000000..6148249f5a047 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { migrateToLatest } from '../../../../kibana_utils/common'; +import type { SerializableState } from '../../../../kibana_utils/common'; +import type { UrlService } from '../../../common/url_service'; +import { render } from './render'; +import { parseSearchParams } from './util/parse_search_params'; + +export interface RedirectOptions { + /** Locator ID. */ + id: string; + + /** Kibana version when locator params where generated. */ + version: string; + + /** Locator params. */ + params: unknown & SerializableState; +} + +export interface RedirectManagerDependencies { + url: UrlService; +} + +export class RedirectManager { + public readonly error$ = new BehaviorSubject<null | Error>(null); + + constructor(public readonly deps: RedirectManagerDependencies) {} + + public registerRedirectApp(core: CoreSetup) { + core.application.register({ + id: 'r', + title: 'Redirect endpoint', + chromeless: true, + mount: (params) => { + const unmount = render(params.element, { manager: this }); + this.onMount(params.history.location.search); + return () => { + unmount(); + }; + }, + }); + } + + public onMount(urlLocationSearch: string) { + const options = this.parseSearchParams(urlLocationSearch); + const locator = this.deps.url.locators.get(options.id); + + if (!locator) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.locatorNotFound', { + defaultMessage: 'Locator [ID = {id}] does not exist.', + values: { + id: options.id, + }, + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator does not exist.', + }); + const error = new Error(message); + this.error$.next(error); + throw error; + } + + const { state: migratedParams } = migrateToLatest(locator.migrations, { + state: options.params, + version: options.version, + }); + + locator + .navigate(migratedParams) + .then() + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Redirect endpoint failed to execute locator redirect.'); + // eslint-disable-next-line no-console + console.error(error); + }); + } + + protected parseSearchParams(urlLocationSearch: string): RedirectOptions { + try { + return parseSearchParams(urlLocationSearch); + } catch (error) { + this.error$.next(error); + throw error; + } + } +} diff --git a/src/plugins/share/public/url_service/redirect/render.ts b/src/plugins/share/public/url_service/redirect/render.ts new file mode 100644 index 0000000000000..2b9c3a50758e4 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/render.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Page, PageProps } from './components/page'; + +export const render = (container: HTMLElement, props: PageProps) => { + ReactDOM.render(React.createElement(Page, props), container); + + return () => { + ReactDOM.unmountComponentAtNode(container); + }; +}; diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts new file mode 100644 index 0000000000000..f8d8d6a6295d9 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { formatSearchParams } from './format_search_params'; +import { parseSearchParams } from './parse_search_params'; + +test('can format typical locator settings as URL path search params', () => { + const search = formatSearchParams({ + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }); + + expect(search.get('l')).toBe('LOCATOR_ID'); + expect(search.get('v')).toBe('7.21.3'); + expect(JSON.parse(search.get('p')!)).toEqual({ + dashboardId: '123', + mode: 'edit', + }); +}); + +test('can format and then parse redirect options', () => { + const options = { + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }; + const formatted = formatSearchParams(options); + const parsed = parseSearchParams(formatted.toString()); + + expect(parsed).toEqual(options); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts new file mode 100644 index 0000000000000..12c6424182a87 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectOptions } from '../redirect_manager'; + +export function formatSearchParams(opts: RedirectOptions): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set('l', opts.id); + searchParams.set('v', opts.version); + searchParams.set('p', JSON.stringify(opts.params)); + + return searchParams; +} diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts new file mode 100644 index 0000000000000..418e21cfd4053 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseSearchParams } from './parse_search_params'; + +test('parses a well constructed URL path search part', () => { + const res = parseSearchParams(`?l=LOCATOR&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`); + + expect(res).toEqual({ + id: 'LOCATOR', + version: '0.0.0', + params: { + foo: 'bar', + }, + }); +}); + +test('throws on missing locator ID', () => { + expect(() => + parseSearchParams(`?v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); + + expect(() => + parseSearchParams(`?l=&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); +}); + +test('throws on missing version', () => { + expect(() => + parseSearchParams(`?l=LOCATOR&v=&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); + + expect(() => + parseSearchParams(`?l=LOCATOR&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); +}); + +test('throws on missing params', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); + + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); +}); + +test('throws if params are not JSON', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=asdf`)).toThrowErrorMatchingInlineSnapshot( + `"Could not parse locator params. Locator params must be serialized as JSON and set at \\"p\\" URL search parameter."` + ); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts new file mode 100644 index 0000000000000..a60c1d1b68a97 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import { i18n } from '@kbn/i18n'; +import type { RedirectOptions } from '../redirect_manager'; + +/** + * Parses redirect endpoint URL path search parameters. Expects them in the + * following form: + * + * ``` + * /r?l=<locator_id>&v=<version>&p=<params> + * ``` + * + * @param urlSearch Search part of URL path. + * @returns Parsed out locator ID, version, and locator params. + */ +export function parseSearchParams(urlSearch: string): RedirectOptions { + const search = new URLSearchParams(urlSearch); + const id = search.get('l'); + const version = search.get('v'); + const paramsJson = search.get('p'); + + if (!id) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamLocator', + { + defaultMessage: + 'Locator ID not specified. Specify "l" search parameter in the URL, which should be an existing locator ID.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing locator ID.', + } + ); + throw new Error(message); + } + + if (!version) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamVersion', + { + defaultMessage: + 'Locator params version not specified. Specify "v" search parameter in the URL, which should be the release version of Kibana when locator params were generated.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing version parameter.', + } + ); + throw new Error(message); + } + + if (!paramsJson) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.missingParamParams', { + defaultMessage: + 'Locator params not specified. Specify "p" search parameter in the URL, which should be JSON serialized object of locator params.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing params parameter.', + }); + throw new Error(message); + } + + let params: unknown & SerializableState; + try { + params = JSON.parse(paramsJson); + } catch { + const message = i18n.translate('share.urlService.redirect.RedirectManager.invalidParamParams', { + defaultMessage: + 'Could not parse locator params. Locator params must be serialized as JSON and set at "p" URL search parameter.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator parameters could not be parsed as JSON.', + }); + throw new Error(message); + } + + return { + id, + version, + params, + }; +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d11e1cf78c960..13caa3c33fa82 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1743,6 +1743,137 @@ } } }, + "r": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "apm": { "properties": { "appId": { From 9773e3f6780095e02240cc4770f4b9d58e12066a Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 12:07:54 +0100 Subject: [PATCH 26/48] skip failing es promotion suite (#104413) --- test/functional/apps/context/_discover_navigation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index a09be8b35ba8f..6a2298ba48cb4 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); - describe('context link in discover', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104413 + describe.skip('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ From f6fc6c1a3d552764c25fa4e3ac28cc99eea32c17 Mon Sep 17 00:00:00 2001 From: Marco Liberati <dej611@users.noreply.github.com> Date: Tue, 6 Jul 2021 14:36:24 +0200 Subject: [PATCH 27/48] Fix new terms enum API when field meta is not passed for autocomplete value suggestions (#104141) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/autocomplete/terms_agg.test.ts | 48 +++++++++++++++++++ .../server/autocomplete/terms_enum.test.ts | 43 +++++++++++++++++ .../data/server/autocomplete/terms_enum.ts | 2 +- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/plugins/data/server/autocomplete/terms_agg.test.ts b/src/plugins/data/server/autocomplete/terms_agg.test.ts index e4652c2c422e2..ae991e289a715 100644 --- a/src/plugins/data/server/autocomplete/terms_agg.test.ts +++ b/src/plugins/data/server/autocomplete/terms_agg.test.ts @@ -32,6 +32,8 @@ const mockResponse = { }, } as ApiResponse<SearchResponse<any>>; +jest.mock('../index_patterns'); + describe('terms agg suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -86,4 +88,50 @@ describe('terms agg suggestions', () => { ] `); }); + + it('calls the _search API with a terms agg and fallback to fieldName when field is null', async () => { + const result = await termsAggSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.search.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "suggestions": Object { + "terms": Object { + "execution_hint": "map", + "field": "fieldName", + "include": "query.*", + "shard_size": 10, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + "size": 0, + "terminate_after": 98430, + "timeout": "4513ms", + }, + "index": "index", + } + `); + expect(result).toMatchInlineSnapshot(` + Array [ + "whoa", + "amazing", + ] + `); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.test.ts b/src/plugins/data/server/autocomplete/terms_enum.test.ts index be8f179db29c0..41eaf3f4032ab 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.test.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.test.ts @@ -22,6 +22,8 @@ const mockResponse = { body: { terms: ['whoa', 'amazing'] }, }; +jest.mock('../index_patterns'); + describe('_terms_enum suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -71,4 +73,45 @@ describe('_terms_enum suggestions', () => { `); expect(result).toEqual(mockResponse.body.terms); }); + + it('calls the _terms_enum API and fallback to fieldName when field is null', async () => { + const result = await termsEnumSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.transport.request.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "field": "fieldName", + "index_filter": Object { + "bool": Object { + "must": Array [ + Object { + "terms": Object { + "_tier": Array [ + "data_hot", + "data_warm", + "data_content", + ], + }, + }, + ], + }, + }, + "string": "query", + }, + "method": "POST", + "path": "/index/_terms_enum", + } + `); + expect(result).toEqual(mockResponse.body.terms); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.ts b/src/plugins/data/server/autocomplete/terms_enum.ts index c2452b0a099d0..40329586a3621 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.ts @@ -36,7 +36,7 @@ export async function termsEnumSuggestions( method: 'POST', path: encodeURI(`/${index}/_terms_enum`), body: { - field: field?.name ?? field, + field: field?.name ?? fieldName, string: query, index_filter: { bool: { From 2d48f7fb1143b3575dc25586d3f4838792517f58 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin <aleh.zasypkin@gmail.com> Date: Tue, 6 Jul 2021 14:41:24 +0200 Subject: [PATCH 28/48] Add `userSetup` plugin skeleton. (#101610) --- .github/CODEOWNERS | 1 + docs/developer/plugin-list.asciidoc | 4 ++++ packages/kbn-optimizer/limits.yml | 1 + src/plugins/user_setup/README.md | 3 +++ src/plugins/user_setup/jest.config.js | 13 +++++++++++ src/plugins/user_setup/kibana.json | 13 +++++++++++ src/plugins/user_setup/public/app.tsx | 27 ++++++++++++++++++++++ src/plugins/user_setup/public/index.ts | 11 +++++++++ src/plugins/user_setup/public/plugin.tsx | 29 ++++++++++++++++++++++++ src/plugins/user_setup/server/config.ts | 16 +++++++++++++ src/plugins/user_setup/server/index.ts | 19 ++++++++++++++++ src/plugins/user_setup/server/plugin.ts | 17 ++++++++++++++ src/plugins/user_setup/tsconfig.json | 12 ++++++++++ 13 files changed, 166 insertions(+) create mode 100644 src/plugins/user_setup/README.md create mode 100644 src/plugins/user_setup/jest.config.js create mode 100644 src/plugins/user_setup/kibana.json create mode 100644 src/plugins/user_setup/public/app.tsx create mode 100644 src/plugins/user_setup/public/index.ts create mode 100644 src/plugins/user_setup/public/plugin.tsx create mode 100644 src/plugins/user_setup/server/config.ts create mode 100644 src/plugins/user_setup/server/index.ts create mode 100644 src/plugins/user_setup/server/plugin.ts create mode 100644 src/plugins/user_setup/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f2d6749813013..5fcb619af6570 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -252,6 +252,7 @@ /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security /src/plugins/spaces_oss/ @elastic/kibana-security +/src/plugins/user_setup/ @elastic/kibana-security /test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index b4be27eee5ed2..ffc918af92514 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -256,6 +256,10 @@ In general this plugin provides: |The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. +|{kib-repo}blob/{branch}/src/plugins/user_setup/README.md[userSetup] +|The plugin provides UI and APIs for the interactive setup mode. + + |{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] |The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6627b644daec7..2c7f194d7da98 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,3 +112,4 @@ pageLoadAssetSize: visTypePie: 35583 expressionRevealImage: 25675 cases: 144442 + userSetup: 18532 diff --git a/src/plugins/user_setup/README.md b/src/plugins/user_setup/README.md new file mode 100644 index 0000000000000..61ec964f5bb80 --- /dev/null +++ b/src/plugins/user_setup/README.md @@ -0,0 +1,3 @@ +# `userSetup` plugin + +The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/user_setup/jest.config.js b/src/plugins/user_setup/jest.config.js new file mode 100644 index 0000000000000..75e355e230c5d --- /dev/null +++ b/src/plugins/user_setup/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['<rootDir>/src/plugins/user_setup'], +}; diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json new file mode 100644 index 0000000000000..192fd42cd3e26 --- /dev/null +++ b/src/plugins/user_setup/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "userSetup", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin provides UI and APIs for the interactive setup mode.", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["userSetup"], + "server": true, + "ui": true +} diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx new file mode 100644 index 0000000000000..2b6b708953972 --- /dev/null +++ b/src/plugins/user_setup/public/app.tsx @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; + +export const App = () => { + return ( + <EuiPageTemplate + restrictWidth={false} + template="empty" + pageHeader={{ + iconType: 'logoElastic', + pageTitle: 'Welcome to Elastic', + }} + > + <EuiPanel> + <EuiText>Kibana server is not ready yet.</EuiText> + </EuiPanel> + </EuiPageTemplate> + ); +}; diff --git a/src/plugins/user_setup/public/index.ts b/src/plugins/user_setup/public/index.ts new file mode 100644 index 0000000000000..153bc92a0dd08 --- /dev/null +++ b/src/plugins/user_setup/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UserSetupPlugin } from './plugin'; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx new file mode 100644 index 0000000000000..677c27cc456dc --- /dev/null +++ b/src/plugins/user_setup/public/plugin.tsx @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { App } from './app'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'userSetup', + title: 'User Setup', + chromeless: true, + mount: (params) => { + ReactDOM.render(<App />, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); + }, + }); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/user_setup/server/config.ts b/src/plugins/user_setup/server/config.ts new file mode 100644 index 0000000000000..b16c51bcbda09 --- /dev/null +++ b/src/plugins/user_setup/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export type ConfigType = TypeOf<typeof ConfigSchema>; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); diff --git a/src/plugins/user_setup/server/index.ts b/src/plugins/user_setup/server/index.ts new file mode 100644 index 0000000000000..2a43cbbf65c9d --- /dev/null +++ b/src/plugins/user_setup/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'src/core/server'; + +import { ConfigSchema } from './config'; +import { UserSetupPlugin } from './plugin'; + +export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = { + schema: ConfigSchema, +}; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts new file mode 100644 index 0000000000000..918c9a2007935 --- /dev/null +++ b/src/plugins/user_setup/server/plugin.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/server'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json new file mode 100644 index 0000000000000..d211a70f12df3 --- /dev/null +++ b/src/plugins/user_setup/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*", "server/**/*"], + "references": [{ "path": "../../core/tsconfig.json" }] +} From 75187b6aeef8a9c2aaab258de3f60f38cd9bb878 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet <nicolas.chaulet@elastic.co> Date: Tue, 6 Jul 2021 09:16:34 -0400 Subject: [PATCH 29/48] [Fleet] Fix powershell command to add fleet server (#104342) --- .../components/install_command_utils.test.ts | 26 +++++++++---------- .../components/install_command_utils.ts | 17 ++++++------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts index e9e7e09207992..b4e7982c52f7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts @@ -31,8 +31,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" `); }); @@ -78,9 +78,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` --fleet-server-policy=policy-1" `); }); @@ -137,14 +137,14 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \\\\ - -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ - --fleet-server-policy=policy-1 \\\\ - --certificate-authorities=<PATH_TO_CA> \\\\ - --fleet-server-es-ca=<PATH_TO_ES_CERT> \\\\ - --fleet-server-cert=<PATH_TO_FLEET_SERVER_CERT> \\\\ + ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` + -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --certificate-authorities=<PATH_TO_CA> \` + --fleet-server-es-ca=<PATH_TO_ES_CERT> \` + --fleet-server-cert=<PATH_TO_FLEET_SERVER_CERT> \` --fleet-server-cert-key=<PATH_TO_FLEET_SERVER_CERT_KEY>" `); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts index b91c4b60aa713..e129d7a4d5b4e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts @@ -16,22 +16,23 @@ export function getInstallCommandForPlatform( isProductionDeployment?: boolean ) { let commandArguments = ''; + const newLineSeparator = platform === 'windows' ? '`' : '\\'; if (isProductionDeployment && fleetServerHost) { - commandArguments += `--url=${fleetServerHost} \\\n`; + commandArguments += `--url=${fleetServerHost} ${newLineSeparator}\n`; } - commandArguments += ` -f \\\n --fleet-server-es=${esHost}`; - commandArguments += ` \\\n --fleet-server-service-token=${serviceToken}`; + commandArguments += ` -f ${newLineSeparator}\n --fleet-server-es=${esHost}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-service-token=${serviceToken}`; if (policyId) { - commandArguments += ` \\\n --fleet-server-policy=${policyId}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-policy=${policyId}`; } if (isProductionDeployment) { - commandArguments += ` \\\n --certificate-authorities=<PATH_TO_CA>`; - commandArguments += ` \\\n --fleet-server-es-ca=<PATH_TO_ES_CERT>`; - commandArguments += ` \\\n --fleet-server-cert=<PATH_TO_FLEET_SERVER_CERT>`; - commandArguments += ` \\\n --fleet-server-cert-key=<PATH_TO_FLEET_SERVER_CERT_KEY>`; + commandArguments += ` ${newLineSeparator}\n --certificate-authorities=<PATH_TO_CA>`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-es-ca=<PATH_TO_ES_CERT>`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert=<PATH_TO_FLEET_SERVER_CERT>`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert-key=<PATH_TO_FLEET_SERVER_CERT_KEY>`; } switch (platform) { From 1ae7afd1ca45e0d1a2ebdb3cd5f419970b3b7477 Mon Sep 17 00:00:00 2001 From: Pablo Machado <pablo.nevesmachado@elastic.co> Date: Tue, 6 Jul 2021 15:25:14 +0200 Subject: [PATCH 30/48] Fix Kibana page crash on redirect navigation when timeline is open (#104288) --- .../public/common/components/endpoint/route_capture.tsx | 6 ------ .../public/common/components/url_state/index.test.tsx | 8 ++++++++ .../common/components/url_state/index_mocked.test.tsx | 8 ++++++++ .../public/common/components/url_state/use_url_state.tsx | 7 ++++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx index a5e0c90402df4..ebd25eef87cb7 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx @@ -9,8 +9,6 @@ import React, { memo, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { AppLocation } from '../../../../common/endpoint/types'; -import { timelineActions } from '../../../timelines/store/timeline'; -import { TimelineId } from '../../../../../timelines/common'; /** * This component should be used above all routes, but below the Provider. @@ -20,10 +18,6 @@ export const RouteCapture = memo(({ children }) => { const location: AppLocation = useLocation(); const dispatch = useDispatch(); - useEffect(() => { - dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); - }, [dispatch, location.pathname]); - useEffect(() => { dispatch({ type: 'userChangedUrl', payload: location }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx index b40799895e8a2..18b99adca3a55 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx @@ -59,6 +59,14 @@ jest.mock('../../lib/kibana', () => ({ }, })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx index e178aba188d11..3175656f12071 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx @@ -31,6 +31,14 @@ jest.mock('../../lib/kibana', () => ({ }), })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer - lodash.throttle mocked to test update url', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 487463dfd9d7d..87e17ba7691cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -9,6 +9,7 @@ import { difference, isEmpty } from 'lodash/fp'; import { useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { useKibana } from '../../lib/kibana'; import { CONSTANTS, UrlStateType } from './constants'; import { @@ -31,6 +32,8 @@ import { UrlState, } from './types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../../timelines/common'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef<PreviousLocationUrlState>(value); @@ -71,6 +74,7 @@ export const useUrlStateHooks = ({ const [isInitializing, setIsInitializing] = useState(true); const { filterManager, savedQueries } = useKibana().services.data.query; const prevProps = usePrevious({ pathName, pageName, urlState }); + const dispatch = useDispatch(); const handleInitialize = (type: UrlStateType, needUpdate?: boolean) => { let mySearch = search; @@ -222,9 +226,10 @@ export const useUrlStateHooks = ({ }); } else if (pathName !== prevProps.pathName) { handleInitialize(type, isDetectionsPages(pageName)); + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitializing, history, pathName, pageName, prevProps, urlState]); + }, [isInitializing, history, pathName, pageName, prevProps, urlState, dispatch]); useEffect(() => { document.title = `${getTitle(pageName, detailName, navTabs)} - Kibana`; From d20de1222c92986bafeafa4386162981602695fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= <alejandro.fernandez@elastic.co> Date: Tue, 6 Jul 2021 15:33:41 +0200 Subject: [PATCH 31/48] [Fleet] Tweak agent permissions (#104415) --- x-pack/plugins/fleet/server/services/agent_policy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index cff70737be6ee..8302983316316 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -773,10 +773,10 @@ class AgentPolicyService { ) { const names: string[] = []; if (fullAgentPolicy.agent.monitoring.logs) { - names.push(`logs-elastic_agent.*-${monitoringNamespace}`); + names.push(`logs-elastic_agent*-${monitoringNamespace}`); } if (fullAgentPolicy.agent.monitoring.metrics) { - names.push(`metrics-elastic_agent.*-${monitoringNamespace}`); + names.push(`metrics-elastic_agent*-${monitoringNamespace}`); } permissions._elastic_agent_checks.indices = [ From df8f870f38a47cda9acdb6d82a1b6c0a08219da1 Mon Sep 17 00:00:00 2001 From: Wylie Conlon <william.conlon@elastic.co> Date: Tue, 6 Jul 2021 09:49:50 -0400 Subject: [PATCH 32/48] [Lens] i18n tinymath help text (#104205) * [Lens] i18n tinymath help text * Fix i18n ids for remaining strings Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../operations/definitions/formula/util.ts | 126 +++++++++++------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index 9806cdaad637e..445df21a6067e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -106,18 +106,20 @@ export const tinymathFunctions: Record< type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.addFunction.markdown', { + defaultMessage: ` Adds up two numbers. Also works with + symbol Example: Calculate the sum of two fields -${'`sum(price) + sum(tax)`'} +\`sum(price) + sum(tax)\` Example: Offset count by a static value -${'`add(count(), 5)`'} +\`add(count(), 5)\` `, + }), }, subtract: { positionalArguments: [ @@ -130,13 +132,15 @@ ${'`add(count(), 5)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.subtractFunction.markdown', { + defaultMessage: ` Subtracts the first number from the second number. -Also works with ${'`-`'} symbol +Also works with \`-\` symbol Example: Calculate the range of a field -${'`subtract(max(bytes), min(bytes))`'} +\`subtract(max(bytes), min(bytes))\` `, + }), }, multiply: { positionalArguments: [ @@ -149,16 +153,18 @@ ${'`subtract(max(bytes), min(bytes))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.multiplyFunction.markdown', { + defaultMessage: ` Multiplies two numbers. -Also works with ${'`*`'} symbol. +Also works with \`*\` symbol. Example: Calculate price after current tax rate -${'`sum(bytes) * last_value(tax_rate)`'} +\`sum(bytes) * last_value(tax_rate)\` Example: Calculate price after constant tax rate -${'`multiply(sum(price), 1.2)`'} +\`multiply(sum(price), 1.2)\` `, + }), }, divide: { positionalArguments: [ @@ -171,15 +177,17 @@ ${'`multiply(sum(price), 1.2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.divideFunction.markdown', { + defaultMessage: ` Divides the first number by the second number. -Also works with ${'`/`'} symbol +Also works with \`/\` symbol Example: Calculate profit margin -${'`sum(profit) / sum(revenue)`'} +\`sum(profit) / sum(revenue)\` -Example: ${'`divide(sum(bytes), 2)`'} +Example: \`divide(sum(bytes), 2)\` `, + }), }, abs: { positionalArguments: [ @@ -188,11 +196,13 @@ Example: ${'`divide(sum(bytes), 2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.absFunction.markdown', { + defaultMessage: ` Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same. -Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} +Example: Calculate average distance to sea level \`abs(average(altitude))\` `, + }), }, cbrt: { positionalArguments: [ @@ -201,12 +211,14 @@ Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cbrtFunction.markdown', { + defaultMessage: ` Cube root of value. Example: Calculate side length from volume -${'`cbrt(last_value(volume))`'} +\`cbrt(last_value(volume))\` `, + }), }, ceil: { positionalArguments: [ @@ -215,13 +227,14 @@ ${'`cbrt(last_value(volume))`'} type: getTypeI18n('number'), }, ], - // signature: 'ceil(value: number)', - help: ` + help: i18n.translate('xpack.lens.formula.ceilFunction.markdown', { + defaultMessage: ` Ceiling of value, rounds up. Example: Round up price to the next dollar -${'`ceil(sum(price))`'} +\`ceil(sum(price))\` `, + }), }, clamp: { positionalArguments: [ @@ -238,8 +251,8 @@ ${'`ceil(sum(price))`'} type: getTypeI18n('number'), }, ], - // signature: 'clamp(value: number, minimum: number, maximum: number)', - help: ` + help: i18n.translate('xpack.lens.formula.clampFunction.markdown', { + defaultMessage: ` Limits the value from a minimum to maximum. Example: Make sure to catch outliers @@ -251,6 +264,7 @@ clamp( ) \`\`\` `, + }), }, cube: { positionalArguments: [ @@ -259,12 +273,14 @@ clamp( type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cubeFunction.markdown', { + defaultMessage: ` Calculates the cube of a number. Example: Calculate volume from side length -${'`cube(last_value(length))`'} +\`cube(last_value(length))\` `, + }), }, exp: { positionalArguments: [ @@ -273,13 +289,15 @@ ${'`cube(last_value(length))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.expFunction.markdown', { + defaultMessage: ` Raises *e* to the nth power. Example: Calculate the natural exponential function -${'`exp(last_value(duration))`'} +\`exp(last_value(duration))\` `, + }), }, fix: { positionalArguments: [ @@ -288,12 +306,14 @@ ${'`exp(last_value(duration))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.fixFunction.markdown', { + defaultMessage: ` For positive values, takes the floor. For negative values, takes the ceiling. Example: Rounding towards zero -${'`fix(sum(profit))`'} +\`fix(sum(profit))\` `, + }), }, floor: { positionalArguments: [ @@ -302,12 +322,14 @@ ${'`fix(sum(profit))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.floorFunction.markdown', { + defaultMessage: ` Round down to nearest integer value Example: Round down a price -${'`floor(sum(price))`'} +\`floor(sum(price))\` `, + }), }, log: { positionalArguments: [ @@ -322,7 +344,8 @@ ${'`floor(sum(price))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.logFunction.markdown', { + defaultMessage: ` Logarithm with optional base. The natural base *e* is used as default. Example: Calculate number of bits required to store values @@ -331,17 +354,8 @@ log(sum(bytes)) log(sum(bytes), 2) \`\`\` `, + }), }, - // TODO: check if this is valid for Tinymath - // log10: { - // positionalArguments: [ - // { name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }), type: getTypeI18n('number') }, - // ], - // help: ` - // Base 10 logarithm. - // Example: ${'`log10(sum(bytes))`'} - // `, - // }, mod: { positionalArguments: [ { @@ -353,12 +367,14 @@ log(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.modFunction.markdown', { + defaultMessage: ` Remainder after dividing the function by a number Example: Calculate last three digits of a value -${'`mod(sum(price), 1000)`'} +\`mod(sum(price), 1000)\` `, + }), }, pow: { positionalArguments: [ @@ -371,12 +387,14 @@ ${'`mod(sum(price), 1000)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.powFunction.markdown', { + defaultMessage: ` Raises the value to a certain power. The second argument is required Example: Calculate volume based on side length -${'`pow(last_value(length), 3)`'} +\`pow(last_value(length), 3)\` `, + }), }, round: { positionalArguments: [ @@ -391,7 +409,8 @@ ${'`pow(last_value(length), 3)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.roundFunction.markdown', { + defaultMessage: ` Rounds to a specific number of decimal places, default of 0 Examples: Round to the cent @@ -400,6 +419,7 @@ round(sum(bytes)) round(sum(bytes), 2) \`\`\` `, + }), }, sqrt: { positionalArguments: [ @@ -408,12 +428,14 @@ round(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.sqrtFunction.markdown', { + defaultMessage: ` Square root of a positive value only Example: Calculate side length based on area -${'`sqrt(last_value(area))`'} +\`sqrt(last_value(area))\` `, + }), }, square: { positionalArguments: [ @@ -422,12 +444,14 @@ ${'`sqrt(last_value(area))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.squareFunction.markdown', { + defaultMessage: ` Raise the value to the 2nd power Example: Calculate area based on side length -${'`square(last_value(length))`'} +\`square(last_value(length))\` `, + }), }, }; From 57fdadbbec2cdd4681d1210a788405ac2a947a21 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 6 Jul 2021 10:02:47 -0400 Subject: [PATCH 33/48] [Docs] Add auth_provider_hint to authentication docs (#104132) --- docs/user/security/authentication/index.asciidoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index faa980fe833cb..5506e7ab375a2 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -65,6 +65,10 @@ image::user/security/images/kibana-login.png["Login Selector UI"] For more information, refer to <<authentication-security-settings, authentication security settings>>. +TIP: If you have multiple authentication providers configured, you can use the `auth_provider_hint` URL query parameter to create a deep +link to any provider and bypass the Login Selector UI. Using the `kibana.yml` above as an example, you can add `?auth_provider_hint=basic1` +to the login page URL, which will take you directly to the basic login page. + [[basic-authentication]] ==== Basic authentication From 434568abe40c192ebfc23c9f4b3c77edcc232dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 6 Jul 2021 10:04:31 -0400 Subject: [PATCH 34/48] [APM] Blank page when navigating to errors metadata (#104322) * using history.location instead of location * removing consoles --- .../components/app/error_group_details/detail_view/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index be3895967d4dc..5a56b64374537 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -157,9 +157,9 @@ export function DetailView({ errorGroup, urlParams }: Props) { <EuiTab onClick={() => { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); From a0b36c75f5028587bc622db3925f6a8f4d939728 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 15:26:39 +0100 Subject: [PATCH 35/48] skip failing es promotion suite (#104466) --- test/functional/apps/discover/_field_data.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 338d17ba31ff4..5ab6495686726 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -33,7 +33,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104466 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { From c72ad3edcb4a78f6bf2429bfa5b765225e166d50 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 15:34:40 +0100 Subject: [PATCH 36/48] skip failing es promotion suite (#104467) --- test/functional/apps/dashboard/view_edit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index b29b07f9df4e4..1ca70112c3d1e 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); - describe('dashboard view edit mode', function viewEditModeTests() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104467 + describe.skip('dashboard view edit mode', function viewEditModeTests() { before(async () => { await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ From 87971e74e1311cdb50a12c02b8dd92c54111c0b7 Mon Sep 17 00:00:00 2001 From: Tiago Costa <tiagoffcc@hotmail.com> Date: Tue, 6 Jul 2021 15:39:08 +0100 Subject: [PATCH 37/48] skip failing es promotion suite (#104469) --- x-pack/test/functional/apps/discover/visualize_field.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index 650d67f05129c..de0dc459b6395 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -28,7 +28,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); } - describe('discover field visualize button', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104469 + describe.skip('discover field visualize button', () => { beforeEach(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/lens/basic'); From 1bc2d9e89ad483dc75a033482647f806899e7c8b Mon Sep 17 00:00:00 2001 From: Sergi Massaneda <sergi.massaneda@elastic.co> Date: Tue, 6 Jul 2021 17:01:07 +0200 Subject: [PATCH 38/48] [Security Solutions] Detect navigation crash fix (#104329) * detections empty timelines crash patch * cleaning unnecesary key --- .../public/timelines/components/flyout/header/index.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index f0c21b6bc1565..eed44afae1695 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -221,7 +221,7 @@ const TimelineNameComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => { [timelineType] ); - const content = useMemo(() => (title.length ? title : placeholder), [title, placeholder]); + const content = useMemo(() => title || placeholder, [title, placeholder]); return ( <EuiText> @@ -239,10 +239,8 @@ const TimelineDescriptionComponent: React.FC<FlyoutHeaderProps> = ({ timelineId ); return ( <EuiText size="s" data-test-subj="timeline-description"> - {description.length ? ( - <LineClamp key={description.length} lineClampHeight={4.5}> - {description} - </LineClamp> + {description ? ( + <LineClamp lineClampHeight={4.5}>{description}</LineClamp> ) : ( commonI18n.DESCRIPTION )} From eb57dd4a7e35ecd3db49b0768adf3a890f33e41b Mon Sep 17 00:00:00 2001 From: Kyle Pollich <kyle.pollich@elastic.co> Date: Tue, 6 Jul 2021 11:05:26 -0400 Subject: [PATCH 39/48] [Fleet] Update "Policies" breadcrumb to "Agent Policies" (#104436) * Update Policies breadcrumb to Agent Policies Closes #103447 * Convert tab titles + breadcrumbs to sentence case --- .../applications/fleet/hooks/use_breadcrumbs.tsx | 10 +++++----- .../applications/fleet/layouts/default/default.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 254885ea71b1e..c0c425447e556 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -42,7 +42,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -50,7 +50,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -59,7 +59,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { text: policyName }, @@ -69,7 +69,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { @@ -100,7 +100,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx index 7ad034b1cc059..dd15020adcc75 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx @@ -49,7 +49,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({ name: ( <FormattedMessage id="xpack.fleet.appNavigation.policiesLinkText" - defaultMessage="Agent Policies" + defaultMessage="Agent policies" /> ), isSelected: section === 'agent_policies', @@ -60,7 +60,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({ name: ( <FormattedMessage id="xpack.fleet.appNavigation.enrollmentTokensText" - defaultMessage="Enrollment Tokens" + defaultMessage="Enrollment tokens" /> ), isSelected: section === 'enrollment_tokens', From 694f8caeb36a7368e2673e76634bdc4e89727c1d Mon Sep 17 00:00:00 2001 From: Corey Robertson <corey.robertson@elastic.co> Date: Tue, 6 Jul 2021 11:25:34 -0400 Subject: [PATCH 40/48] [Canvas] Move away from lib/workpad_service (#104183) * Move away from lib/workpad_service * Adds stubs * Fix types. Swap fetching zip to workpad service * Fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/core/public/http/fetch.ts | 3 +- x-pack/plugins/canvas/i18n/errors.ts | 24 --- .../public/components/home/hooks/index.ts | 1 - .../home/my_workpads/workpad_table.tsx | 3 +- .../home/my_workpads/workpad_table_tools.tsx | 3 +- .../index.tsx} | 6 +- .../public/components/hooks/workpad/index.tsx | 8 + .../hooks/workpad/use_download_workpad.ts | 71 +++++++ .../share_menu/flyout/flyout.component.tsx | 74 +++---- .../flyout/{flyout.ts => flyout.tsx} | 41 ++-- .../share_menu/flyout/hooks/index.ts | 8 + .../flyout/hooks/use_download_runtime.ts | 86 ++++++++ .../workpad_header/share_menu/share_menu.ts | 65 ------ .../workpad_header/share_menu/share_menu.tsx | 68 ++++++ .../canvas/public/lib/download_workpad.ts | 64 ------ .../canvas/public/lib/workpad_service.js | 111 ---------- .../hooks/use_workpad_persist.test.tsx | 200 ++++++++++++++++++ .../workpad/hooks/use_workpad_persist.ts | 89 ++++++++ .../public/routes/workpad/workpad_route.tsx | 2 + .../canvas/public/services/kibana/workpad.ts | 23 ++ .../canvas/public/services/legacy/context.tsx | 7 +- .../public/services/storybook/workpad.ts | 14 ++ .../canvas/public/services/stubs/workpad.ts | 5 + .../plugins/canvas/public/services/workpad.ts | 5 + .../public/state/middleware/es_persist.js | 99 --------- .../canvas/public/state/middleware/index.js | 10 +- .../canvas/public/state/selectors/workpad.ts | 3 +- .../plugins/canvas/shareable_runtime/types.ts | 5 +- x-pack/plugins/canvas/types/state.ts | 2 +- 29 files changed, 645 insertions(+), 455 deletions(-) rename x-pack/plugins/canvas/public/components/{home/hooks/use_download_workpad.ts => hooks/index.tsx} (51%) create mode 100644 x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx create mode 100644 x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts rename x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/{flyout.ts => flyout.tsx} (60%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts delete mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx delete mode 100644 x-pack/plugins/canvas/public/lib/download_workpad.ts delete mode 100644 x-pack/plugins/canvas/public/lib/workpad_service.js create mode 100644 x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx create mode 100644 x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts delete mode 100644 x-pack/plugins/canvas/public/state/middleware/es_persist.js diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 345fcecbda445..87df54f2c6a8a 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -30,6 +30,7 @@ interface Params { const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; +const ZIP_CONTENT = /^(application\/zip)(;.*)?$/; const removedUndefined = (obj: Record<string, any> | undefined) => { return omitBy(obj, (v) => v === undefined); @@ -153,7 +154,7 @@ export class Fetch { const contentType = response.headers.get('Content-Type') || ''; try { - if (NDJSON_CONTENT.test(contentType)) { + if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) { body = await response.blob(); } else if (JSON_CONTENT.test(contentType)) { body = await response.json(); diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index a55762dce2d20..8b6697e78ca37 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -17,30 +17,6 @@ export const ErrorStrings = { }, }), }, - downloadWorkpad: { - getDownloadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { - defaultMessage: "Couldn't download workpad", - }), - getDownloadRenderedWorkpadFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', - { - defaultMessage: "Couldn't download rendered workpad", - } - ), - getDownloadRuntimeFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { - defaultMessage: "Couldn't download Shareable Runtime", - }), - getDownloadZippedRuntimeFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', - { - defaultMessage: "Couldn't download ZIP file", - } - ), - }, esPersist: { getSaveFailureTitle: () => i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts index c4267a9857490..dde9a06e4851d 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/index.ts +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -8,7 +8,6 @@ export { useCloneWorkpad } from './use_clone_workpad'; export { useCreateWorkpad } from './use_create_workpad'; export { useDeleteWorkpads } from './use_delete_workpad'; -export { useDownloadWorkpad } from './use_download_workpad'; export { useFindTemplates } from './use_find_templates'; export { useFindWorkpads } from './use_find_workpad'; export { useImportWorkpad } from './use_upload_workpad'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx index e5d83039a87eb..6d88691f2eabe 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -11,7 +11,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; import { usePlatformService } from '../../../services'; -import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; +import { useCloneWorkpad } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTable as Component } from './workpad_table.component'; import { WorkpadsContext } from './my_workpads'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx index 62d84adfc2649..02b4ee61ea0ca 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -10,7 +10,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; -import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; +import { useDeleteWorkpads } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTableTools as Component, diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/index.tsx similarity index 51% rename from x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts rename to x-pack/plugins/canvas/public/components/hooks/index.tsx index b875e08c2a230..e420ab4cd698c 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts +++ b/x-pack/plugins/canvas/public/components/hooks/index.tsx @@ -5,8 +5,4 @@ * 2.0. */ -import { useCallback } from 'react'; -import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad'; - -export const useDownloadWorkpad = () => - useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []); +export * from './workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx new file mode 100644 index 0000000000000..50d527036560a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDownloadWorkpad, useDownloadRenderedWorkpad } from './use_download_workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts new file mode 100644 index 0000000000000..b688bb5a3b1a5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { useNotifyService, useWorkpadService } from '../../../services'; +import { CanvasWorkpad } from '../../../../types'; +import { CanvasRenderedWorkpad } from '../../../../shareable_runtime/types'; + +const strings = { + getDownloadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { + defaultMessage: "Couldn't download workpad", + }), + getDownloadRenderedWorkpadFailureErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', + { + defaultMessage: "Couldn't download rendered workpad", + } + ), +}; + +export const useDownloadWorkpad = () => { + const notifyService = useNotifyService(); + const workpadService = useWorkpadService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpadId: string) => { + try { + const workpad = await workpadService.get(workpadId); + + download(workpad, `canvas-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); + } + }, + [workpadService, notifyService, download] + ); +}; + +export const useDownloadRenderedWorkpad = () => { + const notifyService = useNotifyService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpad: CanvasRenderedWorkpad) => { + try { + download(workpad, `canvas-embed-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadRenderedWorkpadFailureErrorMessage(), + }); + } + }, + [notifyService, download] + ); +}; + +const useDownloadWorkpadBlob = () => { + return useCallback((workpad: CanvasWorkpad | CanvasRenderedWorkpad, filename: string) => { + const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); + fileSaver.saveAs(jsonBlob, `${filename}.json`); + }, []); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx index be337a6dcf00c..52e80c316c1ef 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { EuiText, EuiSpacer, @@ -24,35 +24,21 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { arrayBufferFetch } from '../../../../../common/lib/fetch'; -import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants'; import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; -import { - downloadRenderedWorkpad, - downloadRuntime, - downloadZippedRuntime, -} from '../../../../lib/download_workpad'; +import { useDownloadRenderedWorkpad } from '../../../hooks'; +import { useDownloadRuntime, useDownloadZippedRuntime } from './hooks'; import { ZIP, CANVAS, HTML } from '../../../../../i18n/constants'; import { OnCloseFn } from '../share_menu.component'; import { WorkpadStep } from './workpad_step'; import { RuntimeStep } from './runtime_step'; import { SnippetsStep } from './snippets_step'; -import { useNotifyService, usePlatformService } from '../../../../services'; +import { useNotifyService } from '../../../../services'; const strings = { getCopyShareConfigMessage: () => i18n.translate('xpack.canvas.workpadHeaderShareMenu.copyShareConfigMessage', { defaultMessage: 'Copied share markup to clipboard', }), - getShareableZipErrorTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { - defaultMessage: - "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", - values: { - ZIP, - workpadName, - }, - }), getUnknownExportErrorMessage: (type: string) => i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { defaultMessage: 'Unknown export type: {type}', @@ -121,33 +107,33 @@ export const ShareWebsiteFlyout: FC<Props> = ({ renderedWorkpad, }) => { const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const onCopy = () => { - notifyService.info(strings.getCopyShareConfigMessage()); - }; - const onDownload = (type: 'share' | 'shareRuntime' | 'shareZip') => { - switch (type) { - case 'share': - downloadRenderedWorkpad(renderedWorkpad); - return; - case 'shareRuntime': - downloadRuntime(platformService.getBasePath()); - case 'shareZip': - const basePath = platformService.getBasePath(); - arrayBufferFetch - .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) - .then((blob) => downloadZippedRuntime(blob.data)) - .catch((err: Error) => { - notifyService.error(err, { - title: strings.getShareableZipErrorTitle(renderedWorkpad.name), - }); - }); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }; + const onCopy = useCallback(() => notifyService.info(strings.getCopyShareConfigMessage()), [ + notifyService, + ]); + + const downloadRenderedWorkpad = useDownloadRenderedWorkpad(); + const downloadRuntime = useDownloadRuntime(); + const downloadZippedRuntime = useDownloadZippedRuntime(); + + const onDownload = useCallback( + (type: 'share' | 'shareRuntime' | 'shareZip') => { + switch (type) { + case 'share': + downloadRenderedWorkpad(renderedWorkpad); + return; + case 'shareRuntime': + downloadRuntime(); + return; + case 'shareZip': + downloadZippedRuntime(renderedWorkpad); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadRenderedWorkpad, downloadRuntime, downloadZippedRuntime, renderedWorkpad] + ); const link = ( <EuiLink diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.tsx similarity index 60% rename from x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts rename to x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.tsx index 0b4d65758b7fd..5ff3f8dd5bb86 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.tsx @@ -5,22 +5,21 @@ * 2.0. */ -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; import { getWorkpad, getRenderedWorkpad, getRenderedWorkpadExpressions, } from '../../../../state/selectors/workpad'; -import { ShareWebsiteFlyout as Component, Props as ComponentProps } from './flyout.component'; + +import { ShareWebsiteFlyout as FlyoutComponent } from './flyout.component'; import { State, CanvasWorkpad } from '../../../../../types'; import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; import { renderFunctionNames } from '../../../../../shareable_runtime/supported_renderers'; -import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public/'; import { OnCloseFn } from '../share_menu.component'; - export { OnDownloadFn, OnCopyFn } from './flyout.component'; const getUnsupportedRenderers = (state: State) => { @@ -35,12 +34,6 @@ const getUnsupportedRenderers = (state: State) => { return renderers; }; -const mapStateToProps = (state: State) => ({ - renderedWorkpad: getRenderedWorkpad(state), - unsupportedRenderers: getUnsupportedRenderers(state), - workpad: getWorkpad(state), -}); - interface Props { onClose: OnCloseFn; renderedWorkpad: CanvasRenderedWorkpad; @@ -48,14 +41,18 @@ interface Props { workpad: CanvasWorkpad; } -export const ShareWebsiteFlyout = compose<ComponentProps, Pick<Props, 'onClose'>>( - connect(mapStateToProps), - withKibana, - withProps( - ({ unsupportedRenderers, renderedWorkpad, onClose, workpad }: Props): ComponentProps => ({ - renderedWorkpad, - unsupportedRenderers, - onClose, - }) - ) -)(Component); +export const ShareWebsiteFlyout: FC<Pick<Props, 'onClose'>> = ({ onClose }) => { + const { renderedWorkpad, unsupportedRenderers } = useSelector((state: State) => ({ + renderedWorkpad: getRenderedWorkpad(state), + unsupportedRenderers: getUnsupportedRenderers(state), + workpad: getWorkpad(state), + })); + + return ( + <FlyoutComponent + onClose={onClose} + unsupportedRenderers={unsupportedRenderers} + renderedWorkpad={renderedWorkpad} + /> + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts new file mode 100644 index 0000000000000..a4243c9fff7e1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './use_download_runtime'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts new file mode 100644 index 0000000000000..dc2e4ff685ca5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../../../../../common/lib/constants'; +import { ZIP } from '../../../../../../i18n/constants'; + +import { usePlatformService, useNotifyService, useWorkpadService } from '../../../../../services'; +import { CanvasRenderedWorkpad } from '../../../../../../shareable_runtime/types'; + +const strings = { + getDownloadRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download Shareable Runtime", + }), + getDownloadZippedRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download ZIP file", + }), + getShareableZipErrorTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { + defaultMessage: + "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", + values: { + ZIP, + workpadName, + }, + }), +}; + +export const useDownloadRuntime = () => { + const platformService = usePlatformService(); + const notifyService = useNotifyService(); + + const downloadRuntime = useCallback(() => { + try { + const path = `${platformService.getBasePath()}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; + window.open(path); + return; + } catch (err) { + notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); + } + }, [platformService, notifyService]); + + return downloadRuntime; +}; + +export const useDownloadZippedRuntime = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + const downloadZippedRuntime = useCallback( + (workpad: CanvasRenderedWorkpad) => { + const downloadZip = async () => { + try { + let runtimeZipBlob: Blob | undefined; + try { + runtimeZipBlob = await workpadService.getRuntimeZip(workpad); + } catch (err) { + notifyService.error(err, { + title: strings.getShareableZipErrorTitle(workpad.name), + }); + } + + if (runtimeZipBlob) { + fileSaver.saveAs(runtimeZipBlob, 'canvas-workpad-embed.zip'); + } + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadZippedRuntimeFailureErrorMessage(), + }); + } + }; + + downloadZip(); + }, + [notifyService, workpadService] + ); + return downloadZippedRuntime; +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts deleted file mode 100644 index f514f813599b6..0000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { i18n } from '@kbn/i18n'; - -import { CanvasWorkpad, State } from '../../../../types'; -import { downloadWorkpad } from '../../../lib/download_workpad'; -import { withServices, WithServicesProps } from '../../../services'; -import { getPages, getWorkpad } from '../../../state/selectors/workpad'; -import { Props as ComponentProps, ShareMenu as Component } from './share_menu.component'; - -const strings = { - getUnknownExportErrorMessage: (type: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { - defaultMessage: 'Unknown export type: {type}', - values: { - type, - }, - }), -}; - -const mapStateToProps = (state: State) => ({ - workpad: getWorkpad(state), - pageCount: getPages(state).length, -}); - -interface Props { - workpad: CanvasWorkpad; - pageCount: number; -} - -export const ShareMenu = compose<ComponentProps, {}>( - connect(mapStateToProps), - withServices, - withProps( - ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => { - const { - reporting: { start: reporting }, - } = services; - - return { - sharingServices: { reporting }, - sharingData: { workpad, pageCount }, - onExport: (type) => { - switch (type) { - case 'pdf': - // notifications are automatically handled by the Reporting plugin - break; - case 'json': - downloadWorkpad(workpad.id); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }; - } - ) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx new file mode 100644 index 0000000000000..0083ff1659c58 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { State } from '../../../../types'; +import { useReportingService } from '../../../services'; +import { getPages, getWorkpad } from '../../../state/selectors/workpad'; +import { useDownloadWorkpad } from '../../hooks'; +import { ShareMenu as ShareMenuComponent } from './share_menu.component'; + +const strings = { + getUnknownExportErrorMessage: (type: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { + defaultMessage: 'Unknown export type: {type}', + values: { + type, + }, + }), +}; + +export const ShareMenu: FC = () => { + const { workpad, pageCount } = useSelector((state: State) => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, + })); + + const reportingService = useReportingService(); + const downloadWorkpad = useDownloadWorkpad(); + + const sharingServices = { + reporting: reportingService.start, + }; + + const sharingData = { + workpad, + pageCount, + }; + + const onExport = useCallback( + (type: string) => { + switch (type) { + case 'pdf': + // notifications are automatically handled by the Reporting plugin + break; + case 'json': + downloadWorkpad(workpad.id); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadWorkpad, workpad] + ); + + return ( + <ShareMenuComponent + sharingServices={sharingServices} + sharingData={sharingData} + onExport={onExport} + /> + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts deleted file mode 100644 index a346de3322d09..0000000000000 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import fileSaver from 'file-saver'; -import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; -import { ErrorStrings } from '../../i18n'; - -// TODO: clint - convert this whole file to hooks -import { pluginServices } from '../services'; - -// @ts-expect-error untyped local -import * as workpadService from './workpad_service'; -import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; - -const { downloadWorkpad: strings } = ErrorStrings; - -export const downloadWorkpad = async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); - fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); - } -}; - -export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWorkpad) => { - try { - const jsonBlob = new Blob([JSON.stringify(renderedWorkpad)], { type: 'application/json' }); - fileSaver.saveAs( - jsonBlob, - `canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json` - ); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); - } -}; - -export const downloadRuntime = async (basePath: string) => { - try { - const path = `${basePath}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; - window.open(path); - return; - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); - } -}; - -export const downloadZippedRuntime = async (data: any) => { - try { - const zip = new Blob([data], { type: 'octet/stream' }); - fileSaver.saveAs(zip, 'canvas-workpad-embed.zip'); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); - } -}; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js deleted file mode 100644 index 20ad82860f1fa..0000000000000 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// TODO: clint - move to workpad service. -import { - API_ROUTE_WORKPAD, - API_ROUTE_WORKPAD_ASSETS, - API_ROUTE_WORKPAD_STRUCTURES, - DEFAULT_WORKPAD_CSS, -} from '../../common/lib/constants'; -import { fetch } from '../../common/lib/fetch'; -import { pluginServices } from '../services'; - -/* - Remove any top level keys from the workpad which will be rejected by validation -*/ -const validKeys = [ - '@created', - '@timestamp', - 'assets', - 'colors', - 'css', - 'variables', - 'height', - 'id', - 'isWriteable', - 'name', - 'page', - 'pages', - 'width', -]; - -const sanitizeWorkpad = function (workpad) { - const workpadKeys = Object.keys(workpad); - - for (const key of workpadKeys) { - if (!validKeys.includes(key)) { - delete workpad[key]; - } - } - - return workpad; -}; - -const getApiPath = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD}`; -}; - -const getApiPathStructures = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; -}; - -const getApiPathAssets = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; -}; - -export function create(workpad) { - return fetch.post(getApiPath(), { - ...sanitizeWorkpad({ ...workpad }), - assets: workpad.assets || {}, - variables: workpad.variables || [], - }); -} - -export async function createFromTemplate(templateId) { - return fetch.post(getApiPath(), { - templateId, - }); -} - -export function get(workpadId) { - return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { - // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; - }); -} - -// TODO: I think this function is never used. Look into and remove the corresponding route as well -export function update(id, workpad) { - return fetch.put(`${getApiPath()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateWorkpad(id, workpad) { - return fetch.put(`${getApiPathStructures()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateAssets(id, workpadAssets) { - return fetch.put(`${getApiPathAssets()}/${id}`, workpadAssets); -} - -export function remove(id) { - return fetch.delete(`${getApiPath()}/${id}`); -} - -export function find(searchTerm) { - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return fetch - .get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) - .then(({ data: workpads }) => workpads); -} diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx new file mode 100644 index 0000000000000..3ef93905f7e31 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { useWorkpadPersist } from './use_workpad_persist'; + +const mockGetState = jest.fn(); +const mockUpdateWorkpad = jest.fn(); +const mockUpdateAssets = jest.fn(); +const mockUpdate = jest.fn(); +const mockNotifyError = jest.fn(); + +// Mock the hooks and actions used by the UseWorkpad hook +jest.mock('react-redux', () => ({ + useSelector: (selector: any) => selector(mockGetState()), +})); + +jest.mock('../../../services', () => ({ + useWorkpadService: () => ({ + updateWorkpad: mockUpdateWorkpad, + updateAssets: mockUpdateAssets, + update: mockUpdate, + }), + useNotifyService: () => ({ + error: mockNotifyError, + }), +})); + +describe('useWorkpadPersist', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('initial render does not persist state', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + renderHook(useWorkpadPersist); + + expect(mockUpdateWorkpad).not.toBeCalled(); + expect(mockUpdateAssets).not.toBeCalled(); + expect(mockUpdate).not.toBeCalled(); + }); + + test('changes to workpad cause a workpad update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + persistent: { + workpad: { new: 'workpad' }, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateWorkpad).toHaveBeenCalled(); + }); + + test('changes to assets cause an asset update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateAssets).toHaveBeenCalled(); + }); + + test('changes to both assets and workpad causes a full update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad' }, + }, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).toHaveBeenCalled(); + }); + + test('non changes causes no updated', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); + + test('non write permissions causes no updates', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad value' }, + }, + assets: { + asset3: 'something', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts new file mode 100644 index 0000000000000..62c83e0411848 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useCallback } from 'react'; +import { isEqual } from 'lodash'; +import usePrevious from 'react-use/lib/usePrevious'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { CanvasWorkpad, State } from '../../../../types'; +import { getWorkpad, getFullWorkpadPersisted } from '../../../state/selectors/workpad'; +import { canUserWrite } from '../../../state/selectors/app'; +import { getAssetIds } from '../../../state/selectors/assets'; +import { useWorkpadService, useNotifyService } from '../../../services'; + +const strings = { + getSaveFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { + defaultMessage: "Couldn't save your changes to Elasticsearch", + }), + getTooLargeErrorMessage: () => + i18n.translate('xpack.canvas.error.esPersist.tooLargeErrorMessage', { + defaultMessage: + 'The server gave a response that the workpad data was too large. This usually means uploaded image assets that are too large for Kibana or a proxy. Try removing some assets in the asset manager.', + }), + getUpdateFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.updateFailureTitle', { + defaultMessage: "Couldn't update workpad", + }), +}; + +export const useWorkpadPersist = () => { + const service = useWorkpadService(); + const notifyService = useNotifyService(); + const notifyError = useCallback( + (err: any) => { + const statusCode = err.response && err.response.status; + switch (statusCode) { + case 400: + return notifyService.error(err.response, { + title: strings.getSaveFailureTitle(), + }); + case 413: + return notifyService.error(strings.getTooLargeErrorMessage(), { + title: strings.getSaveFailureTitle(), + }); + default: + return notifyService.error(err, { + title: strings.getUpdateFailureTitle(), + }); + } + }, + [notifyService] + ); + + // Watch for workpad state or workpad assets to change and then persist those changes + const [workpad, assetIds, fullWorkpad, canWrite]: [ + CanvasWorkpad, + Array<string | number>, + CanvasWorkpad, + boolean + ] = useSelector((state: State) => [ + getWorkpad(state), + getAssetIds(state), + getFullWorkpadPersisted(state), + canUserWrite(state), + ]); + + const previousWorkpad = usePrevious(workpad); + const previousAssetIds = usePrevious(assetIds); + + const workpadChanged = previousWorkpad && workpad !== previousWorkpad; + const assetsChanged = previousAssetIds && !isEqual(assetIds, previousAssetIds); + + useEffect(() => { + if (canWrite) { + if (workpadChanged && assetsChanged) { + service.update(workpad.id, fullWorkpad).catch(notifyError); + } + if (workpadChanged) { + service.updateWorkpad(workpad.id, workpad).catch(notifyError); + } else if (assetsChanged) { + service.updateAssets(workpad.id, fullWorkpad.assets).catch(notifyError); + } + } + }, [service, workpad, fullWorkpad, workpadChanged, assetsChanged, canWrite, notifyError]); +}; diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 95caba08517ee..2c1ad4fcb6aa1 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -20,6 +20,7 @@ import { useWorkpad } from './hooks/use_workpad'; import { useRestoreHistory } from './hooks/use_restore_history'; import { useWorkpadHistory } from './hooks/use_workpad_history'; import { usePageSync } from './hooks/use_page_sync'; +import { useWorkpadPersist } from './hooks/use_workpad_persist'; import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.'; import { WorkpadRoutingContextComponent } from './workpad_routing_context'; import { WorkpadPresentationHelper } from './workpad_presentation_helper'; @@ -88,6 +89,7 @@ export const WorkpadHistoryManager: FC = ({ children }) => { useRestoreHistory(); useWorkpadHistory(); usePageSync(); + useWorkpadPersist(); return <>{children}</>; }; diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index 36ad1c568f9e6..8609d5055cb83 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -14,6 +14,9 @@ import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS, API_ROUTE_TEMPLATES, + API_ROUTE_WORKPAD_ASSETS, + API_ROUTE_WORKPAD_STRUCTURES, + API_ROUTE_SHAREABLE_ZIP, } from '../../../common/lib/constants'; import { CanvasWorkpad } from '../../../types'; @@ -93,5 +96,25 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, remove: (id: string) => { return coreStart.http.delete(`${getApiPath()}/${id}`); }, + update: (id, workpad) => { + return coreStart.http.put(`${getApiPath()}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateWorkpad: (id, workpad) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateAssets: (id, assets) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, { + body: JSON.stringify(assets), + }); + }, + getRuntimeZip: (workpad) => { + return coreStart.http.post<Blob>(API_ROUTE_SHAREABLE_ZIP, { + body: JSON.stringify(workpad), + }); + }, }; }; diff --git a/x-pack/plugins/canvas/public/services/legacy/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx index 2f472afd7d3c1..fb30a9d418df8 100644 --- a/x-pack/plugins/canvas/public/services/legacy/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -26,13 +26,14 @@ const defaultContextValue = { search: {}, }; -const context = createContext<CanvasServices>(defaultContextValue as CanvasServices); +export const ServicesContext = createContext<CanvasServices>(defaultContextValue as CanvasServices); -export const useServices = () => useContext(context); +export const useServices = () => useContext(ServicesContext); export const useEmbeddablesService = () => useServices().embeddables; export const useExpressionsService = () => useServices().expressions; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; +export const useReportingService = () => useServices().reporting; export const withServices = <Props extends WithServicesProps>(type: ComponentType<Props>) => { const EnhancedType: FC<Props> = (props) => @@ -53,5 +54,5 @@ export const LegacyServicesProvider: FC<{ reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), }; - return <context.Provider value={value}>{children}</context.Provider>; + return <ServicesContext.Provider value={value}>{children}</ServicesContext.Provider>; }; diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts index a494f634141bc..cdf4137e1d84c 100644 --- a/x-pack/plugins/canvas/public/services/storybook/workpad.ts +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -97,4 +97,18 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ action('workpadService.remove')(id); return Promise.resolve(); }, + update: (id, workpad) => { + action('worpadService.update')(workpad, id); + return Promise.resolve(); + }, + updateWorkpad: (id, workpad) => { + action('workpadService.updateWorkpad')(workpad, id); + return Promise.resolve(); + }, + updateAssets: (id, assets) => { + action('workpadService.updateAssets')(assets, id); + return Promise.resolve(); + }, + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index eef7508e7c1eb..2f2598563d49b 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -96,4 +96,9 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), find: findNoWorkpads(), remove: (_id: string) => Promise.resolve(), + update: (id, workpad) => Promise.resolve(), + updateWorkpad: (id, workpad) => Promise.resolve(), + updateAssets: (id, assets) => Promise.resolve(), + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 6b90cc346834b..c0e948669647c 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -6,6 +6,7 @@ */ import { CanvasWorkpad, CanvasTemplate } from '../../types'; +import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; export type FoundWorkpads = Array<Pick<CanvasWorkpad, 'name' | 'id' | '@timestamp' | '@created'>>; export type FoundWorkpad = FoundWorkpads[number]; @@ -24,4 +25,8 @@ export interface CanvasWorkpadService { find: (term: string) => Promise<WorkpadFindResponse>; remove: (id: string) => Promise<void>; findTemplates: () => Promise<TemplateFindResponse>; + update: (id: string, workpad: CanvasWorkpad) => Promise<void>; + updateWorkpad: (id: string, workpad: CanvasWorkpad) => Promise<void>; + updateAssets: (id: string, assets: CanvasWorkpad['assets']) => Promise<void>; + getRuntimeZip: (workpad: CanvasRenderedWorkpad) => Promise<Blob>; } diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js deleted file mode 100644 index 17d0c9649b912..0000000000000 --- a/x-pack/plugins/canvas/public/state/middleware/es_persist.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEqual } from 'lodash'; -import { ErrorStrings } from '../../../i18n'; -import { getWorkpad, getFullWorkpadPersisted, getWorkpadPersisted } from '../selectors/workpad'; -import { getAssetIds } from '../selectors/assets'; -import { appReady } from '../actions/app'; -import { setWorkpad, setRefreshInterval, resetWorkpad } from '../actions/workpad'; -import { setAssets, resetAssets } from '../actions/assets'; -import * as transientActions from '../actions/transient'; -import * as resolvedArgsActions from '../actions/resolved_args'; -import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service'; -import { pluginServices } from '../../services'; -import { canUserWrite } from '../selectors/app'; - -const { esPersist: strings } = ErrorStrings; - -const workpadChanged = (before, after) => { - const workpad = getWorkpad(before); - return getWorkpad(after) !== workpad; -}; - -const assetsChanged = (before, after) => { - const assets = getAssetIds(before); - return !isEqual(assets, getAssetIds(after)); -}; - -export const esPersistMiddleware = ({ getState }) => { - // these are the actions we don't want to trigger a persist call - const skippedActions = [ - appReady, // there's no need to resave the workpad once we've loaded it. - resetWorkpad, // used for resetting the workpad in state - setWorkpad, // used for loading and creating workpads - setAssets, // used when loading assets - resetAssets, // used when creating new workpads - setRefreshInterval, // used to set refresh time interval which is a transient value - ...Object.values(resolvedArgsActions), // no resolved args affect persisted values - ...Object.values(transientActions), // no transient actions cause persisted state changes - ].map((a) => a.toString()); - - return (next) => (action) => { - // if the action is in the skipped list, do not persist - if (skippedActions.indexOf(action.type) >= 0) { - return next(action); - } - - // capture state before and after the action - const curState = getState(); - next(action); - const newState = getState(); - - // skips the update request if user doesn't have write permissions - if (!canUserWrite(newState)) { - return; - } - - const notifyError = (err) => { - const statusCode = err.response && err.response.status; - const notifyService = pluginServices.getServices().notify; - - switch (statusCode) { - case 400: - return notifyService.error(err.response, { - title: strings.getSaveFailureTitle(), - }); - case 413: - return notifyService.error(strings.getTooLargeErrorMessage(), { - title: strings.getSaveFailureTitle(), - }); - default: - return notifyService.error(err, { - title: strings.getUpdateFailureTitle(), - }); - } - }; - - const changedWorkpad = workpadChanged(curState, newState); - const changedAssets = assetsChanged(curState, newState); - - if (changedWorkpad && changedAssets) { - // if both the workpad and the assets changed, save it in its entirety to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return update(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedWorkpad) { - // if the workpad changed, save it to elasticsearch - const persistedWorkpad = getWorkpadPersisted(getState()); - return updateWorkpad(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedAssets) { - // if the assets changed, save it to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return updateAssets(persistedWorkpad.id, persistedWorkpad.assets).catch(notifyError); - } - }; -}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index 713232543fab1..fbed2fbb3741b 100644 --- a/x-pack/plugins/canvas/public/state/middleware/index.js +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -8,21 +8,13 @@ import { applyMiddleware, compose as reduxCompose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { getWindow } from '../../lib/get_window'; -import { esPersistMiddleware } from './es_persist'; import { inFlight } from './in_flight'; import { workpadUpdate } from './workpad_update'; import { elementStats } from './element_stats'; import { resolvedArgs } from './resolved_args'; const middlewares = [ - applyMiddleware( - thunkMiddleware, - elementStats, - resolvedArgs, - esPersistMiddleware, - inFlight, - workpadUpdate - ), + applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate), ]; // compose with redux devtools, if extension is installed diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index e1cebeb65bd21..9cfccf3fc5598 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -7,6 +7,7 @@ import { get, omit } from 'lodash'; import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; +import { CanvasRenderedWorkpad } from '../../../shareable_runtime/types'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; import { @@ -500,7 +501,7 @@ export function getRenderedWorkpad(state: State) { return { pages: renderedPages, ...rest, - }; + } as CanvasRenderedWorkpad; } export function getRenderedWorkpadExpressions(state: State) { diff --git a/x-pack/plugins/canvas/shareable_runtime/types.ts b/x-pack/plugins/canvas/shareable_runtime/types.ts index ac8f140b7f11d..751fb3f795524 100644 --- a/x-pack/plugins/canvas/shareable_runtime/types.ts +++ b/x-pack/plugins/canvas/shareable_runtime/types.ts @@ -24,15 +24,14 @@ export interface CanvasRenderedElement { * Represents a Page within a Canvas Workpad that is made up of ready-to- * render Elements. */ -export interface CanvasRenderedPage extends Omit<Omit<CanvasPage, 'elements'>, 'groups'> { +export interface CanvasRenderedPage extends Omit<CanvasPage, 'elements'> { elements: CanvasRenderedElement[]; - groups: CanvasRenderedElement[][]; } /** * A Canvas Workpad made up of ready-to-render Elements. */ -export interface CanvasRenderedWorkpad extends Omit<CanvasWorkpad, 'pages'> { +export interface CanvasRenderedWorkpad extends Omit<CanvasWorkpad, 'pages' | 'variables'> { pages: CanvasRenderedPage[]; } diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 6e27093379e31..cc42839ddfac7 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -94,7 +94,7 @@ interface PersistentState { export interface State { app: StoreAppState; - assets: { [assetKey: string]: AssetType | undefined }; + assets: { [assetKey: string]: AssetType }; transient: TransientState; persistent: PersistentState; } From 3d2c2ed1caeafb1b5fc734dd1356fe9354f279df Mon Sep 17 00:00:00 2001 From: Lisa Cawley <lcawley@elastic.co> Date: Tue, 6 Jul 2021 08:27:38 -0700 Subject: [PATCH 41/48] [DOCS] Fixes links to anomaly detection overview (#104350) --- docs/user/ml/index.asciidoc | 2 +- src/core/public/doc_links/doc_links_service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index 3c463da842faa..b3606b122d750 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -80,7 +80,7 @@ browser so that it does not block pop-up windows or create an exception for your For more information about the {anomaly-detect} feature, see https://www.elastic.co/what-is/elastic-stack-machine-learning[{ml-cap} in the {stack}] -and {ml-docs}/xpack-ml.html[{ml-cap} {anomaly-detect}]. +and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index f215c86d9d507..305a06e60bc0b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -231,7 +231,7 @@ export class DocLinksService { ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, - anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, + anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-overview.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`, From 763ba305d4f951b0fedab8e85f4f193690fece53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= <davidsansol92@gmail.com> Date: Tue, 6 Jul 2021 17:38:14 +0200 Subject: [PATCH 42/48] [Security Solution][Endpoint] Event filters text adjustments to be consistent with trusted apps (#104438) * Text adjustments to be consistent with trusted apps * Changes flyout submit button text --- .../pages/event_filters/view/components/empty/index.tsx | 6 +++--- .../pages/event_filters/view/components/flyout/index.tsx | 4 ++-- .../pages/event_filters/view/event_filters_list_page.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx index d448b7644cc24..9ad2549c85642 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx @@ -29,14 +29,14 @@ export const EventFiltersListEmptyState = memo<{ <h2> <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.title" - defaultMessage="Add your first Endpoint Event Filter" + defaultMessage="Add your first event filter" /> </h2> } body={ <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.message" - defaultMessage="There are currently no Endpoint Event Filters on your endpoint." + defaultMessage="There are currently no event filters on your endpoint." /> } actions={ @@ -48,7 +48,7 @@ export const EventFiltersListEmptyState = memo<{ > <FormattedMessage id="xpack.securitySolution.eventFilters.listEmpty.addButton" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add Event Filter" /> </EuiButton> } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index c45741c1520b1..9f81d25520524 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -94,12 +94,12 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo( {id ? ( <FormattedMessage id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update" - defaultMessage="Update Endpoint Event Filter" + defaultMessage="Update event filter" /> ) : ( <FormattedMessage id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add event filter" /> )} </EuiButton> diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 1f3b721fd51e3..2d608bdc6e157 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -211,7 +211,7 @@ export const EventFiltersListPage = memo(() => { > <FormattedMessage id="xpack.securitySolution.eventFilters.list.pageAddButton" - defaultMessage="Add Endpoint Event Filter" + defaultMessage="Add Event Filter" /> </EuiButton> ) From 901ad6391aceb6c464f826e268e035eb5e9e5ab8 Mon Sep 17 00:00:00 2001 From: Dima Arnautov <dmitrii.arnautov@elastic.co> Date: Tue, 6 Jul 2021 17:39:13 +0200 Subject: [PATCH 43/48] [Transform] Fix aggregation name override for the `top_metrics` aggs (#104446) * [Transform] Fix aggName for the top_metrics agg * [Transform] update comment --- .../step_define/common/get_default_aggregation_config.ts | 6 +++++- .../components/step_define/common/top_metrics_agg/config.ts | 3 --- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 39594dcbff9ae..6667388fd3688 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -43,7 +43,11 @@ export function getDefaultAggregationConfig( case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); case PIVOT_SUPPORTED_AGGS.TOP_METRICS: - return getTopMetricsAggConfig(commonConfig); + return getTopMetricsAggConfig({ + ...commonConfig, + // top_metrics agg has different naming convention by default + aggName: PIVOT_SUPPORTED_AGGS.TOP_METRICS, + }); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts index 354a326f38659..56d17e7973e16 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts @@ -17,7 +17,6 @@ import { import { PivotAggsConfigTopMetrics } from './types'; import { TopMetricsAggForm } from './components/top_metrics_agg_form'; import { isPopulatedObject } from '../../../../../../../../common/shared_imports'; -import { PIVOT_SUPPORTED_AGGS } from '../../../../../../../../common/types/pivot_aggs'; /** * Gets initial basic configuration of the top_metrics aggregation. @@ -31,8 +30,6 @@ export function getTopMetricsAggConfig( isMultiField: true, field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', AggFormComponent: TopMetricsAggForm, - /** Default name */ - aggName: PIVOT_SUPPORTED_AGGS.TOP_METRICS, aggConfig: {}, getEsAggConfig() { // ensure the configuration has been completed From 5b4938078793c9c9f8bdc9d4756553e47a3f0d59 Mon Sep 17 00:00:00 2001 From: Domenico Andreoli <domenico.andreoli@elastic.co> Date: Tue, 6 Jul 2021 18:20:54 +0200 Subject: [PATCH 44/48] CCS Cypress integration (#103941) * Add CCS Cypress test runner * Split flow for CCS Cypress tests * Make esArchiver load data onto the remote cluster * Add CCS specific rules with customizable remote name * Allow overriding @kbn/dev-utils's CA_CERT_PATH * Add CCS related docs Co-authored-by: Gloria Hornero <snootchie.boochies@gmail.com> --- packages/kbn-dev-utils/src/certs.ts | 2 +- .../security_solution/cypress/README.md | 72 +++++++++++++++++++ .../detection_alerts/alerts_details.spec.ts | 72 +++++++++++++++++++ .../security_solution/cypress/objects/rule.ts | 20 ++++++ .../cypress/tasks/es_archiver.ts | 18 +++++ x-pack/plugins/security_solution/package.json | 2 + .../security_solution_cypress/ccs_config.ts | 19 +++++ .../test/security_solution_cypress/runner.ts | 24 +++++++ 8 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts create mode 100644 x-pack/test/security_solution_cypress/ccs_config.ts diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index ca1e2d69b1329..9d1a6077d53c1 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -8,7 +8,7 @@ import { resolve } from 'path'; -export const CA_CERT_PATH = resolve(__dirname, '../certs/ca.crt'); +export const CA_CERT_PATH = process.env.TEST_CA_CERT_PATH || resolve(__dirname, '../certs/ca.crt'); export const ES_KEY_PATH = resolve(__dirname, '../certs/elasticsearch.key'); export const ES_CERT_PATH = resolve(__dirname, '../certs/elasticsearch.crt'); export const ES_P12_PATH = resolve(__dirname, '../certs/elasticsearch.p12'); diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index 0713716a15d51..1b486ca3a5fcd 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -115,8 +115,42 @@ cd x-pack/plugins/security_solution CYPRESS_BASE_URL=http(s)://<username>:<password>@<kbnUrl> CYPRESS_ELASTICSEARCH_URL=http(s)://<username>:<password>@<elsUrl> CYPRESS_ELASTICSEARCH_USERNAME=<username> CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run:firefox ``` +#### CCS Custom Target + Headless + +This test execution requires two clusters configured for CCS. See [Search across clusters](https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cross-cluster-search.html) for instructions on how to prepare such setup. + +The instructions below assume: +* Search cluster is on server1 +* Remote cluster is on server2 +* Remote cluster is accessible from the search cluster with name `remote` +* Security and TLS are enabled + +```shell +# bootstrap Kibana from the project root +yarn kbn bootstrap + +# launch the Cypress test runner with overridden environment variables +cd x-pack/plugins/security_solution +CYPRESS_ELASTICSEARCH_USERNAME="user" \ +CYPRESS_ELASTICSEARCH_PASSWORD="pass" \ +CYPRESS_BASE_URL="https://user:pass@server1:5601" \ +CYPRESS_ELASTICSEARCH_URL="https://user:pass@server1:9200" \ +CYPRESS_CCS_KIBANA_URL="https://user:pass@server2:5601" \ +CYPRESS_CCS_ELASTICSEARCH_URL="https://user:pass@server2:9200" \ +CYPRESS_CCS_REMOTE_NAME="remote" \ +yarn cypress:run:ccs +``` + +Similar sequence, just ending with `yarn cypress:open:ccs`, can be used for interactive test running via Cypress UI. + +Appending `--browser firefox` to the `yarn cypress:run:ccs` command above will run the tests on Firefox instead of Chrome. + ## Folder Structure +### ccs_integration/ + +Contains the specs that are executed in a Cross Cluster Search configuration, typically during integration tests. + ### integration/ Cypress convention. Contains the specs that are going to be executed. @@ -208,6 +242,44 @@ Because of `cy.exec`, used to invoke `es_archiver`, it's necessary to override i > Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification. +### CCS + +Tests running in CCS configuration need to care about two aspects: + +1. data (eg. to trigger alerts) is generated/loaded on the remote cluster +2. queries (eg. detection rules) refer to remote indices + +Incorrect handling of the above points might result in false positives, in that the remote cluster is not involved but the test passes anyway. + +#### Remote data loading + +Helpers `esArchiverCCSLoad` and `esArchiverCCSUnload` are provided by [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts): + +```javascript +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; +``` + +They will use the `CYPRESS_CCS_*_URL` environment variables for accessing the remote cluster. Complex tests involving local and remote data can interleave them with `esArchiverLoad` and `esArchiverUnload` as needed. + +#### Remote indices queries + +Queries accessing remote indices follow the usual `<remote_name>:<remote_index>` notation but should not hard-code the remote name in the test itself. + +For such reason the environemnt variable `CYPRESS_CCS_REMOTE_NAME` is defined and, in the case of detection rules, used as shown below: + +```javascript +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + ... +}; + +``` + +Similar approach should be used in defining all index patterns, rules, and queries to be applied on remote data. + ## Development Best Practices ### Clean up the state diff --git a/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts new file mode 100644 index 0000000000000..f87399a666904 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; + +import { + expandFirstAlert, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../../tasks/alerts'; +import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; + +import { unmappedCCSRule } from '../../objects/rule'; + +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Alert details with unmapped fields', () => { + beforeEach(() => { + cleanKibana(); + esArchiverCCSLoad('unmapped_fields'); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(unmappedCCSRule); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + expandFirstAlert(); + }); + + afterEach(() => { + esArchiverCCSUnload('unmapped_fields'); + }); + + it('Displays the unmapped field on the JSON view', () => { + const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + cy.wrap(elements) + .eq(length - expectedUnmappedField.line) + .should('have.text', expectedUnmappedField.text); + }); + }); + + it('Displays the unmapped field on the table', () => { + const expectedUnmmappedField = { + row: 55, + field: 'unmapped', + text: 'This is the unmapped field', + }; + + openTable(); + + cy.get(TABLE_ROWS) + .eq(expectedUnmmappedField.row) + .within(() => { + cy.get(CELL_TEXT).eq(0).should('have.text', expectedUnmmappedField.field); + cy.get(CELL_TEXT).eq(1).should('have.text', expectedUnmmappedField.text); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 9a8626f2a0d7d..3383ef4996ead 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -16,6 +16,8 @@ export const totalNumberOfPrebuiltRulesInEsArchive = 127; export const totalNumberOfPrebuiltRulesInEsArchiveCustomRule = 145; +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + interface MitreAttackTechnique { name: string; subtechniques: string[]; @@ -198,6 +200,24 @@ export const unmappedRule: CustomRule = { maxSignals: 100, }; +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + name: 'Rule with unmapped fields', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + runsEvery, + lookBack, + timeline, + maxSignals: 100, +}; + export const existingRule: CustomRule = { customQuery: 'host.name: *', name: 'Rule 1', diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index 94ac8003c0d8b..83ec1536baf0f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -11,6 +11,8 @@ const ES_ARCHIVE_DIR = '../../test/security_solution_cypress/es_archives'; const CONFIG_PATH = '../../test/functional/config.js'; const ES_URL = Cypress.env('ELASTICSEARCH_URL'); const KIBANA_URL = Cypress.config().baseUrl; +const CCS_ES_URL = Cypress.env('CCS_ELASTICSEARCH_URL'); +const CCS_KIBANA_URL = Cypress.env('CCS_KIBANA_URL'); // Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https const NODE_TLS_REJECT_UNAUTHORIZED = '1'; @@ -37,3 +39,19 @@ export const esArchiverResetKibana = () => { { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; + +export const esArchiverCCSLoad = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; + +export const esArchiverCCSUnload = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 104c6120ecb39..5362454d3b46b 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -9,10 +9,12 @@ "build-beat-doc": "node scripts/beat_docs/build.js && node ../../../scripts/eslint ./server/utils/beat_schema/fields.ts --fix", "cypress": "../../../node_modules/.bin/cypress", "cypress:open": "yarn cypress open --config-file ./cypress/cypress.json", + "cypress:open:ccs": "yarn cypress:open --config integrationFolder=./cypress/ccs_integration", "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:run": "yarn cypress:run:reporter --browser chrome --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", + "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "junit:merge": "../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json && ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results && mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/", diff --git a/x-pack/test/security_solution_cypress/ccs_config.ts b/x-pack/test/security_solution_cypress/ccs_config.ts new file mode 100644 index 0000000000000..8e679c071ac0f --- /dev/null +++ b/x-pack/test/security_solution_cypress/ccs_config.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +import { SecuritySolutionCypressCcsTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const securitySolutionCypressConfig = await readConfigFile(require.resolve('./config.ts')); + return { + ...securitySolutionCypressConfig.getAll(), + + testRunner: SecuritySolutionCypressCcsTestRunner, + }; +} diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index b219c491ddf77..0ac671f143d03 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -88,6 +88,30 @@ export async function SecuritySolutionCypressCliFirefoxTestRunner({ }); } +export async function SecuritySolutionCypressCcsTestRunner({ getService }: FtrProviderContext) { + const log = getService('log'); + + await withProcRunner(log, async (procs) => { + await procs.run('cypress', { + cmd: 'yarn', + args: ['cypress:run:ccs'], + cwd: resolve(__dirname, '../../plugins/security_solution'), + env: { + FORCE_COLOR: '1', + CYPRESS_BASE_URL: process.env.TEST_KIBANA_URL, + CYPRESS_ELASTICSEARCH_URL: process.env.TEST_ES_URL, + CYPRESS_ELASTICSEARCH_USERNAME: process.env.ELASTICSEARCH_USERNAME, + CYPRESS_ELASTICSEARCH_PASSWORD: process.env.ELASTICSEARCH_PASSWORD, + CYPRESS_CCS_KIBANA_URL: process.env.TEST_KIBANA_URLDATA, + CYPRESS_CCS_ELASTICSEARCH_URL: process.env.TEST_ES_URLDATA, + CYPRESS_CCS_REMOTE_NAME: process.env.TEST_CCS_REMOTE_NAME, + ...process.env, + }, + wait: true, + }); + }); +} + export async function SecuritySolutionCypressVisualTestRunner({ getService }: FtrProviderContext) { const log = getService('log'); const config = getService('config'); From cf9e88c7d7e23f155abbb58f9d2687980f999a5e Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Tue, 6 Jul 2021 12:28:21 -0400 Subject: [PATCH 45/48] [RAC] ALerts table in observability (#103270) Closes #98611 ## Summary Add alerts table in Observability => ![image](https://user-images.githubusercontent.com/189600/123854490-c68ddf00-d8ec-11eb-897e-2217249d5fba.png) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/master/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- x-pack/plugins/observability/kibana.json | 1 + .../pages/alerts/alerts_flyout/index.tsx | 33 +-- .../public/pages/alerts/alerts_search_bar.tsx | 15 +- .../pages/alerts/alerts_table_t_grid.tsx | 197 ++++++++++++++++++ .../alerts/alerts_table_t_grid_actions.tsx | 84 ++++++++ .../public/pages/alerts/index.tsx | 98 +++++---- .../public/pages/alerts/render_cell_value.tsx | 101 +++++++++ x-pack/plugins/observability/tsconfig.json | 1 + .../field_map/runtime_type_from_fieldmap.ts | 52 ++++- .../components/timeline/footer/index.tsx | 10 +- .../timelines/public/components/index.tsx | 4 +- .../public/components/loading/index.tsx | 2 +- .../t_grid/body/column_headers/helpers.ts | 23 +- .../t_grid/body/data_driven_columns/index.tsx | 4 +- .../t_grid/body/events/event_column_view.tsx | 3 +- .../public/components/t_grid/footer/index.tsx | 6 +- .../components/t_grid/standalone/index.tsx | 144 +++++++------ .../timelines/public/components/tgrid.tsx | 4 +- .../timelines/public/methods/index.tsx | 14 +- x-pack/plugins/timelines/public/plugin.ts | 10 +- .../timelines/public/store/t_grid/helpers.ts | 1 - .../timelines/public/store/t_grid/model.ts | 5 +- .../applications/timelines_test/index.tsx | 13 +- 23 files changed, 652 insertions(+), 173 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index d13140f0be16c..6bd96e012548d 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -18,6 +18,7 @@ "data", "features", "ruleRegistry", + "timelines", "triggersActionsUi" ], "ui": true, diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c7faa28b04685..53b5300e556c5 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -31,7 +31,7 @@ import { } from '@kbn/rule-data-utils/target/technical_field_names'; import moment from 'moment-timezone'; import React, { useMemo } from 'react'; -import type { TopAlertResponse } from '../'; +import type { TopAlert, TopAlertResponse } from '../'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; import { asDuration } from '../../../../common/utils/formatters'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; @@ -39,6 +39,7 @@ import { decorateResponse } from '../decorate_response'; import { SeverityBadge } from '../severity_badge'; type AlertsFlyoutProps = { + alert?: TopAlert; alerts?: TopAlertResponse[]; isInApp?: boolean; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; @@ -46,6 +47,7 @@ type AlertsFlyoutProps = { } & EuiFlyoutProps; export function AlertsFlyout({ + alert, alerts, isInApp = false, observabilityRuleTypeRegistry, @@ -59,9 +61,12 @@ export function AlertsFlyout({ const decoratedAlerts = useMemo(() => { return decorateResponse(alerts ?? [], observabilityRuleTypeRegistry); }, [alerts, observabilityRuleTypeRegistry]); - const alert = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); - if (!alert) { + let alertData = alert; + if (!alertData) { + alertData = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); + } + if (!alertData) { return null; } @@ -70,45 +75,45 @@ export function AlertsFlyout({ title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', { defaultMessage: 'Status', }), - description: alert.active ? 'Active' : 'Recovered', + description: alertData.active ? 'Active' : 'Recovered', }, { title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', { defaultMessage: 'Severity', }), - description: <SeverityBadge severityLevel={alert.fields[ALERT_SEVERITY_LEVEL]} />, + description: <SeverityBadge severityLevel={alertData.fields[ALERT_SEVERITY_LEVEL]} />, }, { title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', { defaultMessage: 'Triggered', }), description: ( - <span title={alert.start.toString()}>{moment(alert.start).format(dateFormat)}</span> + <span title={alertData.start.toString()}>{moment(alertData.start).format(dateFormat)}</span> ), }, { title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', { defaultMessage: 'Duration', }), - description: asDuration(alert.fields[ALERT_DURATION], { extended: true }), + description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }), }, { title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', { defaultMessage: 'Expected value', }), - description: alert.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', + description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', { defaultMessage: 'Actual value', }), - description: alert.fields[ALERT_EVALUATION_VALUE] ?? '-', + description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', { defaultMessage: 'Rule type', }), - description: alert.fields[RULE_CATEGORY] ?? '-', + description: alertData.fields[RULE_CATEGORY] ?? '-', }, ]; @@ -116,10 +121,10 @@ export function AlertsFlyout({ <EuiFlyout onClose={onClose} size="s"> <EuiFlyoutHeader> <EuiTitle size="m"> - <h2>{alert.fields[RULE_NAME]}</h2> + <h2>{alertData.fields[RULE_NAME]}</h2> </EuiTitle> <EuiSpacer size="s" /> - <EuiText size="s">{alert.reason}</EuiText> + <EuiText size="s">{alertData.reason}</EuiText> </EuiFlyoutHeader> <EuiFlyoutBody> <EuiSpacer size="s" /> @@ -129,11 +134,11 @@ export function AlertsFlyout({ listItems={overviewListItems} /> </EuiFlyoutBody> - {alert.link && !isInApp && ( + {alertData.link && !isInApp && ( <EuiFlyoutFooter> <EuiFlexGroup justifyContent="flexEnd"> <EuiFlexItem grow={false}> - <EuiButton href={prepend && prepend(alert.link)} fill> + <EuiButton href={prepend && prepend(alertData.link)} fill> View in app </EuiButton> </EuiFlexItem> diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index c0a08fa7faac7..b2d44f9a598dd 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -7,17 +7,17 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo, useState } from 'react'; -import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; +import { IIndexPattern, SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { callObservabilityApi } from '../../services/call_observability_api'; export function AlertsSearchBar({ + dynamicIndexPattern, rangeFrom, rangeTo, onQueryChange, query, }: { + dynamicIndexPattern: IIndexPattern[]; rangeFrom?: string; rangeTo?: string; query?: string; @@ -31,16 +31,9 @@ export function AlertsSearchBar({ }, []); const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery'); - const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', - }); - }, []); - return ( <SearchBar - indexPatterns={dynamicIndexPattern ? [dynamicIndexPattern] : []} + indexPatterns={dynamicIndexPattern} placeholder={i18n.translate('xpack.observability.alerts.searchBarPlaceholder', { defaultMessage: '"domain": "ecommerce" AND ("service.name": "ProductCatalogService" …)', })} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx new file mode 100644 index 0000000000000..5a69c7c9af158 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonIcon, EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Suspense, useState } from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { TimelinesUIStart } from '../../../../timelines/public'; +import type { TopAlert } from './'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import type { ActionProps, ColumnHeaderOptions, RowRenderer } from '../../../../timelines/common'; +import { getRenderCellValue } from './render_cell_value'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { decorateResponse } from './decorate_response'; +import { LazyAlertsFlyout } from '../..'; + +interface AlertsTableTGridProps { + indexName: string; + rangeFrom: string; + rangeTo: string; + kuery: string; + status: string; + setRefetch: (ref: () => void) => void; +} + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick<EuiDataGridColumn, 'display' | 'displayAsText' | 'id' | 'initialWidth'> & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', { + defaultMessage: 'Status', + }), + id: ALERT_STATUS, + initialWidth: 79, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.triggeredColumnDescription', { + defaultMessage: 'Triggered', + }), + id: ALERT_START, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.durationColumnDescription', { + defaultMessage: 'Duration', + }), + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.severityColumnDescription', { + defaultMessage: 'Severity', + }), + id: ALERT_SEVERITY_LEVEL, + initialWidth: 102, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonColumnDescription', { + defaultMessage: 'Reason', + }), + linkField: '*', + id: RULE_NAME, + initialWidth: 400, + }, +]; + +const NO_ROW_RENDER: RowRenderer[] = []; + +const trailingControlColumns: never[] = []; + +export function AlertsTableTGrid(props: AlertsTableTGridProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { prepend } = core.http.basePath; + const { indexName, rangeFrom, rangeTo, kuery, status, setRefetch } = props; + const [flyoutAlert, setFlyoutAlert] = useState<TopAlert | undefined>(undefined); + const handleFlyoutClose = () => setFlyoutAlert(undefined); + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + + const leadingControlColumns = [ + { + id: 'expand', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + <EuiButtonIcon + size="s" + iconType="expand" + color="text" + onClick={() => setFlyoutAlert(alert)} + /> + ); + }, + }, + { + id: 'view_in_app', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + <EuiButtonIcon + size="s" + target="_blank" + rel="nofollow noreferrer" + href={prepend(alert.link ?? '')} + iconType="inspect" + color="text" + /> + ); + }, + }, + ]; + + return ( + <> + {flyoutAlert && ( + <Suspense fallback={null}> + <LazyAlertsFlyout + alert={flyoutAlert} + observabilityRuleTypeRegistry={observabilityRuleTypeRegistry} + onClose={handleFlyoutClose} + /> + </Suspense> + )} + {timelines.getTGrid<'standalone'>({ + type: 'standalone', + columns, + deletedEventIds: [], + end: rangeTo, + filters: [], + indexNames: [indexName], + itemsPerPage: 10, + itemsPerPageOptions: [10, 25, 50], + loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { + defaultMessage: 'loading alerts', + }), + footerText: i18n.translate('xpack.observability.alertsTable.footerTextLabel', { + defaultMessage: 'alerts', + }), + query: { + query: `${ALERT_STATUS}: ${status}${kuery !== '' ? ` and ${kuery}` : ''}`, + language: 'kuery', + }, + renderCellValue: getRenderCellValue({ rangeFrom, rangeTo, setFlyoutAlert }), + rowRenderers: NO_ROW_RENDER, + start: rangeFrom, + setRefetch, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + }, + ], + leadingControlColumns, + trailingControlColumns, + unit: (totalAlerts: number) => + i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', { + values: { totalAlerts }, + defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}', + }), + })} + </> + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx new file mode 100644 index 0000000000000..38919857e86c1 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RULE_ID, RULE_NAME } from '@kbn/rule-data-utils/target/technical_field_names'; +import React, { useState } from 'react'; +import { format, parse } from 'url'; + +import { parseTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; +import type { ActionProps } from '../../../../timelines/common'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +export function RowCellActionsRender({ data }: ActionProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { prepend } = core.http.basePath; + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const parsedFields = parseTechnicalFields(dataFieldEs); + const formatter = observabilityRuleTypeRegistry.getFormatter(parsedFields[RULE_ID]!); + const formatted = { + link: undefined, + reason: parsedFields[RULE_NAME]!, + ...(formatter?.({ fields: parsedFields, formatters: { asDuration, asPercent } }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + const link = parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }) + : undefined; + return ( + <div> + <EuiPopover + isOpen={isPopoverOpen} + panelPaddingSize="s" + anchorPosition="upCenter" + button={ + <EuiButtonIcon + aria-label="show actions" + iconType="boxesHorizontal" + color="text" + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + > + <EuiPopoverTitle>Actions</EuiPopoverTitle> + <div style={{ width: 150 }}> + <EuiButtonEmpty href={prepend(link ?? '')}> + <EuiFlexGroup alignItems="center" component="span" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiButtonIcon aria-label="view in app" iconType="link" color="text" /> + </EuiFlexItem> + <EuiFlexItem> + {i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', { + defaultMessage: 'View in app', + })} + </EuiFlexItem> + </EuiFlexGroup> + </EuiButtonEmpty> + </div> + </EuiPopover> + </div> + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 6f696a70665ce..fed9ee0be3a4a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -7,21 +7,20 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertStatus } from '../../../common/typings'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; -import { callObservabilityApi } from '../../services/call_observability_api'; import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; -import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertsTable } from './alerts_table'; +import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -41,6 +40,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { core, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); + const refetch = useRef<() => void>(); const { query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; @@ -59,37 +59,52 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); - const { data: alerts } = useFetcher( - ({ signal }) => { - const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); + + const dynamicIndexPattern = useMemo( + () => (dynamicIndexPatternResp ? [dynamicIndexPatternResp] : []), + [dynamicIndexPatternResp] + ); + + const setStatusFilter = useCallback( + (value: AlertStatus) => { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }, + [history] + ); - if (!start || !end) { - return; + const onQueryChange = useCallback( + ({ dateRange, query }) => { + if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { + return refetch.current && refetch.current(); } - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/top', - params: { - query: { - start, - end, - kuery, - status, - }, - }, + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), }); }, - [kuery, rangeFrom, rangeTo, status] + [history, rangeFrom, rangeTo, kuery] ); - function setStatusFilter(value: AlertStatus) { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('status', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - } + const setRefetch = useCallback((ref) => { + refetch.current = ref; + }, []); return ( <ObservabilityPageTemplate @@ -135,21 +150,11 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { </EuiFlexItem> <EuiFlexItem> <AlertsSearchBar + dynamicIndexPattern={dynamicIndexPattern} rangeFrom={rangeFrom} rangeTo={rangeTo} query={kuery} - onQueryChange={({ dateRange, query }) => { - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - }} + onQueryChange={onQueryChange} /> </EuiFlexItem> <EuiSpacer size="s" /> @@ -162,7 +167,14 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { </EuiFlexGroup> </EuiFlexItem> <EuiFlexItem> - <AlertsTable items={alerts ?? []} /> + <AlertsTableTGrid + indexName={dynamicIndexPattern.length > 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + /> </EuiFlexItem> </EuiFlexGroup> </EuiFlexGroup> diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx new file mode 100644 index 0000000000000..1cd86631197c4 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconTip, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { CellValueElementProps, TimelineNonEcsData } from '../../../../timelines/common'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; +import { asDuration } from '../../../common/utils/formatters'; +import { SeverityBadge } from './severity_badge'; +import { TopAlert } from '.'; +import { decorateResponse } from './decorate_response'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const getRenderCellValue = ({ + rangeTo, + rangeFrom, + setFlyoutAlert, +}: { + rangeTo: string; + rangeFrom: string; + setFlyoutAlert: (data: TopAlert) => void; +}) => { + return ({ columnId, data, linkValues }: CellValueElementProps) => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]); + + switch (columnId) { + case ALERT_STATUS: + return value !== 'closed' ? ( + <EuiIconTip + content={i18n.translate('xpack.observability.alertsTGrid.statusOpenDescription', { + defaultMessage: 'Open', + })} + color="danger" + type="alert" + /> + ) : ( + <EuiIconTip + content={i18n.translate('xpack.observability.alertsTGrid.statusClosedDescription', { + defaultMessage: 'Closed', + })} + type="check" + /> + ); + case ALERT_START: + return <TimestampTooltip time={new Date(value ?? '').getTime()} timeUnit="milliseconds" />; + case ALERT_DURATION: + return asDuration(Number(value), { extended: true }); + case ALERT_SEVERITY_LEVEL: + return <SeverityBadge severityLevel={value ?? undefined} />; + case RULE_NAME: + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + + return ( + <EuiLink onClick={() => setFlyoutAlert && setFlyoutAlert(alert)}>{alert.reason}</EuiLink> + ); + default: + return <>{value}</>; + } + }; +}; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index b6ed0a0a3d17f..8aa184bca913f 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../cases/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../rule_registry/tsconfig.json" }, + { "path": "../timelines/tsconfig.json"}, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 039424d34bfa1..fe3504c84115b 100644 --- a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -6,22 +6,56 @@ */ import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; +import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import { FieldMap } from './types'; +const NumberFromString = new t.Type( + 'NumberFromString', + (u): u is number => typeof u === 'number', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + const d = Number(s); + return isNaN(d) ? t.failure(u, c) : t.success(d); + }), + (a) => a +); + +const BooleanFromString = new t.Type( + 'BooleanFromString', + (u): u is boolean => typeof u === 'boolean', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + switch (s.toLowerCase().trim()) { + case '1': + case 'true': + case 'yes': + return t.success(true); + case '0': + case 'false': + case 'no': + case null: + return t.success(false); + default: + return t.failure(u, c); + } + }), + (a) => a +); + const esFieldTypeMap = { keyword: t.string, text: t.string, date: t.string, - boolean: t.boolean, - byte: t.number, - long: t.number, - integer: t.number, - short: t.number, - double: t.number, - float: t.number, - scaled_float: t.number, - unsigned_long: t.number, + boolean: t.union([t.number, BooleanFromString]), + byte: t.union([t.number, NumberFromString]), + long: t.union([t.number, NumberFromString]), + integer: t.union([t.number, NumberFromString]), + short: t.union([t.number, NumberFromString]), + double: t.union([t.number, NumberFromString]), + float: t.union([t.number, NumberFromString]), + scaled_float: t.union([t.number, NumberFromString]), + unsigned_long: t.union([t.number, NumberFromString]), flattened: t.record(t.string, t.array(t.string)), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index ac6f6e52db1e2..b71cbb4c082ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,7 +130,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -164,7 +164,13 @@ export const EventsCountComponent = ({ > <EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" /> </PopoverRowItems> - <EuiToolTip content={`${totalCount} ${footerText}`}> + <EuiToolTip + content={ + <> + {totalCount} {footerText} + </> + } + > <ServerSideEventCount> <EuiBadge color="hollow" data-test-subj="server-side-event-count"> {totalCount} diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index b242c0ec2a4a7..8bb4e6cb45853 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -26,13 +26,15 @@ type TGridComponent = TGridProps & { store?: Store; storage: Storage; data?: DataPublicPluginStart; + setStore: (store: Store) => void; }; export const TGrid = (props: TGridComponent) => { - const { store, storage, ...tGridProps } = props; + const { store, storage, setStore, ...tGridProps } = props; let tGridStore = store; if (!tGridStore && props.type === 'standalone') { tGridStore = createStore(initialTGridState, storage); + setStore(tGridStore); } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx index 59cc18767af21..652cb6a5dae33 100644 --- a/x-pack/plugins/timelines/public/components/loading/index.tsx +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -17,7 +17,7 @@ SpinnerFlexItem.displayName = 'SpinnerFlexItem'; export interface LoadingPanelProps { dataTestSubj?: string; - text: string; + text: string | React.ReactNode; height: number | string; showBorder?: boolean; width: number | string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts index fc566da8c58a2..6c793e132b7e3 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -23,17 +23,18 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 23e94b92eaf3d..c164d0026fdf8 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -135,7 +135,7 @@ const TgridActionTdCell = ({ rowIndex, hasRowRenderers, onRuleChange, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, showNotes = false, tabType, @@ -267,7 +267,7 @@ export const DataDrivenColumns = React.memo<DataDrivenColumnProps>( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index dca3b84eb84b7..2db1bde08bd0c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -58,7 +58,7 @@ export const EventColumnView = React.memo<Props>( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, @@ -82,7 +82,6 @@ export const EventColumnView = React.memo<Props>( .join(' '), [columnHeaders, data] ); - const leadingActionCells = useMemo( () => leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx index 2978759b6d148..b7fb0b40c0345 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -110,7 +110,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -144,7 +144,7 @@ export const EventsCountComponent = ({ > <EuiContextMenuPanel items={items} data-test-subj="timelinePickSizeRow" /> </PopoverRowItems> - <EuiToolTip content={`${totalCount} ${footerText}`}> + <EuiToolTip content={`${totalCount} ${footerText?.toString()}`}> <ServerSideEventCount> <EuiBadge color="hollow" data-test-subj="server-side-event-count"> {totalCount} @@ -305,7 +305,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={`${loadingText}...`} + text={loadingText} width="100%" /> </LoadingPanelContainer> diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75aae2ed55c4b..c267a0e57dd2c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -40,6 +40,7 @@ import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; import * as i18n from './translations'; +import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -103,7 +104,9 @@ export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; deletedEventIds: Readonly<string[]>; end: string; + loadingText: React.ReactNode; filters: Filter[]; + footerText: React.ReactNode; headerFilterGroup?: React.ReactNode; height?: number; indexNames: string[]; @@ -113,6 +116,7 @@ export interface TGridStandaloneProps { onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setRefetch: (ref: () => void) => void; start: string; sort: SortColumnTimeline[]; utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; @@ -120,13 +124,17 @@ export interface TGridStandaloneProps { leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; data?: DataPublicPluginStart; + unit: (total: number) => React.ReactNode; } +const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ columns, deletedEventIds, end, + loadingText, filters, + footerText, headerFilterGroup, indexNames, itemsPerPage, @@ -135,6 +143,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ query, renderCellValue, rowRenderers, + setRefetch, start, sort, utilityBar, @@ -142,6 +151,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ leadingControlColumns, trailingControlColumns, data, + unit = basicUnit, }) => { const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -155,7 +165,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ queryFields, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); useEffect(() => { dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading })); }, [dispatch, isQueryLoading]); @@ -216,6 +225,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ skip: !canQueryTimeline, data, }); + setRefetch(refetch); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), @@ -268,71 +278,81 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({ showCheckboxes: false, }) ); + dispatch( + tGridActions.initializeTGridSettings({ + footerText, + id: STANDALONE_ID, + loadingText, + unit, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - <StyledEuiPanel data-test-subj="events-viewer-panel" $isFullScreen={false}> - {canQueryTimeline ? ( - <> - <HeaderSection - id={!resolverIsShowing(graphEventId) ? STANDALONE_ID : undefined} - inspect={inspect} - loading={loading} - height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} - subtitle={utilityBar ? undefined : subtitle} - title={justTitle} - // title={globalFullScreen ? titleWithExitFullScreen : justTitle} - > - {HeaderSectionContent} - </HeaderSection> - {utilityBar && !resolverIsShowing(graphEventId) && ( - <UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar> - )} - <EventsContainerLoading - data-timeline-id={STANDALONE_ID} - data-test-subj={`events-container-loading-${loading}`} - > - <FullWidthFlexGroup $visible={!graphEventId} gutterSize="none"> - <ScrollableFlexItem grow={1}> - <StatefulBody - activePage={pageInfo.activePage} - browserFields={EMPTY_BROWSER_FIELDS} - data={nonDeletedEvents} - id={STANDALONE_ID} - isEventViewer={true} - onRuleChange={onRuleChange} - renderCellValue={renderCellValue} - rowRenderers={rowRenderers} - sort={sort} - tabType={TimelineTabs.query} - totalPages={calculateTotalPages({ - itemsCount: totalCountMinusDeleted, - itemsPerPage: itemsPerPageStore, - })} - leadingControlColumns={leadingControlColumns} - trailingControlColumns={trailingControlColumns} - /> - <Footer - activePage={pageInfo.activePage} - data-test-subj="events-viewer-footer" - updatedAt={updatedAt} - height={footerHeight} - id={STANDALONE_ID} - isLive={false} - isLoading={loading} - itemsCount={nonDeletedEvents.length} - itemsPerPage={itemsPerPageStore} - itemsPerPageOptions={itemsPerPageOptionsStore} - onChangePage={loadPage} - totalCount={totalCountMinusDeleted} - /> - </ScrollableFlexItem> - </FullWidthFlexGroup> - </EventsContainerLoading> - </> - ) : null} - </StyledEuiPanel> + <InspectButtonContainer> + <StyledEuiPanel data-test-subj="events-viewer-panel" $isFullScreen={false}> + {canQueryTimeline ? ( + <> + <HeaderSection + id={!resolverIsShowing(graphEventId) ? STANDALONE_ID : undefined} + inspect={inspect} + loading={loading} + height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} + subtitle={utilityBar ? undefined : subtitle} + title={justTitle} + // title={globalFullScreen ? titleWithExitFullScreen : justTitle} + > + {HeaderSectionContent} + </HeaderSection> + {utilityBar && !resolverIsShowing(graphEventId) && ( + <UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar> + )} + <EventsContainerLoading + data-timeline-id={STANDALONE_ID} + data-test-subj={`events-container-loading-${loading}`} + > + <FullWidthFlexGroup $visible={!graphEventId} gutterSize="none"> + <ScrollableFlexItem grow={1}> + <StatefulBody + activePage={pageInfo.activePage} + browserFields={EMPTY_BROWSER_FIELDS} + data={nonDeletedEvents} + id={STANDALONE_ID} + isEventViewer={true} + onRuleChange={onRuleChange} + renderCellValue={renderCellValue} + rowRenderers={rowRenderers} + sort={sort} + tabType={TimelineTabs.query} + totalPages={calculateTotalPages({ + itemsCount: totalCountMinusDeleted, + itemsPerPage: itemsPerPageStore, + })} + leadingControlColumns={leadingControlColumns} + trailingControlColumns={trailingControlColumns} + /> + <Footer + activePage={pageInfo.activePage} + data-test-subj="events-viewer-footer" + updatedAt={updatedAt} + height={footerHeight} + id={STANDALONE_ID} + isLive={false} + isLoading={loading} + itemsCount={nonDeletedEvents.length} + itemsPerPage={itemsPerPageStore} + itemsPerPageOptions={itemsPerPageOptionsStore} + onChangePage={loadPage} + totalCount={totalCountMinusDeleted} + /> + </ScrollableFlexItem> + </FullWidthFlexGroup> + </EventsContainerLoading> + </> + ) : null} + </StyledEuiPanel> + </InspectButtonContainer> ); }; diff --git a/x-pack/plugins/timelines/public/components/tgrid.tsx b/x-pack/plugins/timelines/public/components/tgrid.tsx index 9d74c9287236a..45e387b780a80 100644 --- a/x-pack/plugins/timelines/public/components/tgrid.tsx +++ b/x-pack/plugins/timelines/public/components/tgrid.tsx @@ -9,12 +9,12 @@ import React from 'react'; import type { TGridProps } from '../types'; import { TGridIntegrated, TGridIntegratedProps } from './t_grid/integrated'; -import { TGridStandalone } from './t_grid/standalone'; +import { TGridStandalone, TGridStandaloneProps } from './t_grid/standalone'; export const TGrid = (props: TGridProps) => { const { type, ...componentsProps } = props; if (type === 'standalone') { - return <TGridStandalone {...componentsProps} />; + return <TGridStandalone {...((componentsProps as unknown) as TGridStandaloneProps)} />; } else if (type === 'embedded') { return <TGridIntegrated {...((componentsProps as unknown) as TGridIntegratedProps)} />; } diff --git a/x-pack/plugins/timelines/public/methods/index.tsx b/x-pack/plugins/timelines/public/methods/index.tsx index cd98021c500c5..a11485f32b6a5 100644 --- a/x-pack/plugins/timelines/public/methods/index.tsx +++ b/x-pack/plugins/timelines/public/methods/index.tsx @@ -16,11 +16,21 @@ import { LastUpdatedAtProps, LoadingPanelProps } from '../components'; const TimelineLazy = lazy(() => import('../components')); export const getTGridLazy = ( props: TGridProps, - { store, storage, data }: { store?: Store; storage: Storage; data: DataPublicPluginStart } + { + store, + storage, + data, + setStore, + }: { + store?: Store; + storage: Storage; + data: DataPublicPluginStart; + setStore: (store: Store) => void; + } ) => { return ( <Suspense fallback={<EuiLoadingSpinner />}> - <TimelineLazy {...props} store={store} storage={storage} data={data} /> + <TimelineLazy {...props} store={store} storage={storage} data={data} setStore={setStore} /> </Suspense> ); }; diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index a6076d91eea1d..c9534d4312e78 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -39,6 +39,7 @@ export class TimelinesPlugin implements Plugin<void, TimelinesUIStart> { return getTGridLazy(props, { store: this._store, storage: this._storage, + setStore: this.setStore.bind(this), data, }); }, @@ -60,12 +61,15 @@ export class TimelinesPlugin implements Plugin<void, TimelinesUIStart> { getUseDraggableKeyboardWrapper: () => { return useDraggableKeyboardWrapper; }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setTGridEmbeddedStore: (store: any) => { - this._store = store; + setTGridEmbeddedStore: (store: Store) => { + this.setStore(store); }, }; } + private setStore(store: Store) { + this._store = store; + } + public stop() {} } diff --git a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts index e114f4516c79e..8bcf246dadb03 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/helpers.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/helpers.ts @@ -234,7 +234,6 @@ export const updateTimelineColumns = ({ timelineById, }: UpdateTimelineColumnsParams): TimelineById => { const timeline = timelineById[id]; - return { ...timelineById, [id]: { diff --git a/x-pack/plugins/timelines/public/store/t_grid/model.ts b/x-pack/plugins/timelines/public/store/t_grid/model.ts index 67b56540c8a42..ed788d1ee4677 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/model.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/model.ts @@ -26,12 +26,13 @@ export interface TGridModelSettings { /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; filterManager?: FilterManager; - footerText: string; - loadingText: string; + footerText?: string | React.ReactNode; + loadingText?: string | React.ReactNode; queryFields: string[]; selectAll: boolean; showCheckboxes?: boolean; title: string; + unit?: (n: number) => string | React.ReactNode; } export interface TGridModel extends TGridModelSettings { /** The columns displayed in the timeline */ diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx index 084b79d6a32b3..072944dd9d78e 100644 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx @@ -6,7 +6,7 @@ */ import { Router } from 'react-router-dom'; -import React from 'react'; +import React, { useCallback, useRef } from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; @@ -45,6 +45,11 @@ const AppRoot = React.memo( parameters: AppMountParameters; timelinesPluginSetup: TimelinesUIStart | null; }) => { + const refetch = useRef(); + + const setRefetch = useCallback((_refetch) => { + refetch.current = _refetch; + }, []); return ( <I18nProvider> <Router history={parameters.history}> @@ -56,10 +61,12 @@ const AppRoot = React.memo( columns: [], indexNames: [], deletedEventIds: [], + end: '', + footerText: 'Events', filters: [], itemsPerPage: 50, itemsPerPageOptions: [1, 2, 3], - end: '', + loadingText: 'Loading events', renderCellValue: () => <div data-test-subj="timeline-wrapper">test</div>, sort: [], leadingControlColumns: [], @@ -68,8 +75,10 @@ const AppRoot = React.memo( query: '', language: 'kuery', }, + setRefetch, start: '', rowRenderers: [], + unit: (n: number) => `${n}`, })) ?? null} </KibanaContextProvider> From 71ad073d67830b1f57db3f20347345fc38d1c5c2 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola <michael.olorunnisola@elastic.co> Date: Tue, 6 Jul 2021 13:17:07 -0400 Subject: [PATCH 46/48] [Security Solution][Analyze Event] Fix resolver range slider (#104475) --- .../public/resolver/view/graph_controls.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 1f520a1847053..96a59383b1a4e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -21,6 +21,7 @@ import { EuiIconTip, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiRangeProps, } from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; @@ -32,6 +33,13 @@ import { StyledDescriptionList } from './panels/styles'; import { CubeForProcess } from './panels/cube_for_process'; import { GeneratedText } from './generated_text'; +// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position +const StyledEuiRange = styled(EuiRange)<EuiRangeProps>` + & .euiRangeTrack:after { + left: -65px; + transform: rotate(90deg); + } +`; interface StyledGraphControlProps { $backgroundColor: string; $iconColor: string; @@ -275,7 +283,7 @@ export const GraphControls = React.memo( > <EuiIcon type="plusInCircle" /> </button> - <EuiRange + <StyledEuiRange className="zoom-slider" data-test-subj="resolver:graph-controls:zoom-slider" min={0} From 8564b3af0f89f0a2536c9c424829ab20d130dad4 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 6 Jul 2021 18:17:27 +0100 Subject: [PATCH 47/48] [Security Solution] Remove total count for events table on hosts and details page (#104114) * remove total count for events table on hosts and details page * hide events count when analyzing an event * fix unit tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../events_viewer/events_viewer.test.tsx | 15 ++++++++++++++- .../components/events_viewer/events_viewer.tsx | 10 ++++++++-- .../common/components/events_viewer/index.tsx | 3 +++ .../pages/navigation/events_query_tab_body.tsx | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 90a4e67d76b99..ccba97f6a7942 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -179,7 +179,7 @@ describe('EventsViewer', () => { mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); }); - test('it renders the "Showing..." subtitle with the expected event count', () => { + test('it renders the "Showing..." subtitle with the expected event count by default', () => { const wrapper = mount( <TestProviders> <StatefulEventsViewer {...testProps} /> @@ -190,6 +190,19 @@ describe('EventsViewer', () => { ); }); + test('should not render the "Showing..." subtitle with the expected event count if showTotalCount is set to false ', () => { + const disableSubTitle = { + ...eventsViewerDefaultProps, + showTotalCount: false, + }; + const wrapper = mount( + <TestProviders> + <EventsViewer {...disableSubTitle} graphEventId="a valid id" /> + </TestProviders> + ); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual(''); + }); + test('it renders the Fields Browser as a settings gear', () => { const wrapper = mount( <TestProviders> 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 c2f170c58043d..b8b6b9766bdde 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 @@ -135,6 +135,7 @@ interface Props { rowRenderers: RowRenderer[]; start: string; sort: Sort[]; + showTotalCount?: boolean; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -163,6 +164,7 @@ const EventsViewerComponent: React.FC<Props> = ({ rowRenderers, start, sort, + showTotalCount = true, utilityBar, graphEventId, }) => { @@ -253,8 +255,12 @@ const EventsViewerComponent: React.FC<Props> = ({ const subtitle = useMemo( () => - `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, - [totalCountMinusDeleted, unit] + showTotalCount + ? `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( + totalCountMinusDeleted + )}` + : null, + [showTotalCount, totalCountMinusDeleted, unit] ); const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 32aa716d4bce3..bfc14a0f0c680 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -10,6 +10,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; +import { isEmpty } from 'lodash/fp'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -40,6 +41,7 @@ export interface OwnProps { id: TimelineId; scopeId: SourcererScopeName; start: string; + showTotalCount?: boolean; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; onRuleChange?: () => void; @@ -176,6 +178,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({ rowRenderers={rowRenderers} start={start} sort={sort} + showTotalCount={isEmpty(graphEventId) ? true : false} utilityBar={utilityBar} graphEventId={graphEventId} /> diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 973dbc41925da..86bd8b5f47b0b 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -65,6 +65,7 @@ const EventsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> = ({ }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ From d5ca24209895ddf4265d96be75dd256ae79fab0b Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 6 Jul 2021 18:22:51 +0100 Subject: [PATCH 48/48] fix links for rule monitoring table (#104473) --- .../integration/detection_rules/links.spec.ts | 37 +++++++++++++++++++ .../cypress/screens/alerts_detection_rules.ts | 2 + .../detection_engine/rules/all/columns.tsx | 7 +++- .../rules/all/rules_tables.tsx | 4 +- 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts new file mode 100644 index 0000000000000..fdc4bce677f74 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { newRule } from '../../objects/rule'; +import { RULES_MONIROTING_TABLE, RULE_NAME } from '../../screens/alerts_detection_rules'; +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana, reload } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Rules talbes links', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + goToManageAlertsDetectionRules(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(newRule, 'rule1'); + + reload(); + }); + + it('should render correct link for rule name - rules', () => { + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); + + it('should render correct link for rule name - rule monitoring', () => { + cy.get(RULES_MONIROTING_TABLE).first().click(); + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index ba071184d98eb..0bf0e5a09e328 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -72,6 +72,8 @@ export const RULES_TABLE = '[data-test-subj="rules-table"]'; export const RULES_ROW = '.euiTableRow'; +export const RULES_MONIROTING_TABLE = '[data-test-subj="allRulesTableTab-monitoring"]'; + export const SEVENTH_RULE = 6; export const SEVERITY = '[data-test-subj="severity"]'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 8d0492267258f..c6145a70ec8d2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -313,7 +313,7 @@ export const getColumns = ({ }; export const getMonitoringColumns = ( - history: H.History, + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise<void>, formatUrl: FormatUrl ): RulesStatusesColumns[] => { const cols: RulesStatusesColumns[] = [ @@ -326,7 +326,10 @@ export const getMonitoringColumns = ( data-test-subj="ruleName" onClick={(ev: { preventDefault: () => void }) => { ev.preventDefault(); - history.push(getRuleDetailsUrl(item.id)); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(item.id), + }); }} href={formatUrl(getRuleDetailsUrl(item.id))} > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 77ca5be0c0ac1..22281fa2c8687 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -300,8 +300,8 @@ export const RulesTables = React.memo<RulesTableProps>( reFetchRules, ]); - const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ - history, + const monitoringColumns = useMemo(() => getMonitoringColumns(navigateToApp, formatUrl), [ + navigateToApp, formatUrl, ]);