From bbda20619ea31f430570fa2b9e1f78142d44cbc5 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 8 Feb 2021 16:20:56 +0100 Subject: [PATCH 01/18] [Search Sessions] Disable "save session" due to timeout (#90294) --- x-pack/plugins/data_enhanced/public/plugin.ts | 4 + ...onnected_search_session_indicator.test.tsx | 139 ++++++++++++++++-- .../connected_search_session_indicator.tsx | 118 ++++++++++----- .../search_session_tour.tsx | 21 +-- .../search_session_indicator.stories.tsx | 6 +- .../search_session_indicator.test.tsx | 16 +- .../search_session_indicator.tsx | 68 +++++---- .../services/search_sessions.ts | 4 +- 8 files changed, 276 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index b7d7b7c0e20d10..0a116545e6e366 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -6,6 +6,7 @@ */ import React from 'react'; +import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; @@ -86,6 +87,9 @@ export class DataEnhancedPlugin application: core.application, timeFilter: plugins.data.query.timefilter.timefilter, storage: this.storage, + disableSaveAfterSessionCompletesTimeout: moment + .duration(this.config.search.sessions.notTouchedTimeout) + .asMilliseconds(), }) ) ), diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index 79e49050941be4..3437920ed7c98a 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { StubBrowserStorage } from '@kbn/test/jest'; import { render, waitFor, screen, act } from '@testing-library/react'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public/'; @@ -20,6 +20,8 @@ import { } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; const coreStart = coreMock.createStart(); const dataStart = dataPluginMock.createStartContract(); @@ -30,6 +32,12 @@ const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked refreshInterval$); timeFilter.getRefreshInterval.mockImplementation(() => refreshInterval$.getValue()); +const disableSaveAfterSessionCompletesTimeout = 5 * 60 * 1000; + +function Container({ children }: { children?: ReactNode }) { + return {children}; +} + beforeEach(() => { storage = new Storage(new StubBrowserStorage()); refreshInterval$.next({ value: 0, pause: true }); @@ -47,8 +55,13 @@ test("shouldn't show indicator in case no active search session", async () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const { getByTestId, container } = render(); + const { getByTestId, container } = render( + + + + ); // make sure `searchSessionIndicator` isn't appearing after some time (lazy-loading) await expect( @@ -69,8 +82,13 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const { getByTestId, container } = render(); + const { getByTestId, container } = render( + + + + ); sessionService.isSessionStorageReady.mockImplementation(() => false); // make sure `searchSessionIndicator` isn't appearing after some time (lazy-loading) @@ -93,8 +111,13 @@ test('should show indicator in case there is an active search session', async () application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const { getByTestId } = render(); + const { getByTestId } = render( + + + + ); await waitFor(() => getByTestId('searchSessionIndicator')); }); @@ -118,13 +141,20 @@ test('should be disabled in case uiConfig says so ', async () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - render(); + render( + + + + ); await waitFor(() => screen.getByTestId('searchSessionIndicator')); - expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled(); + await userEvent.click(screen.getByLabelText('Search session loading')); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); }); test('should be disabled during auto-refresh', async () => { @@ -135,19 +165,82 @@ test('should be disabled during auto-refresh', async () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - render(); + render( + + + + ); await waitFor(() => screen.getByTestId('searchSessionIndicator')); - expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).not.toBeDisabled(); + await userEvent.click(screen.getByLabelText('Search session loading')); + + expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); act(() => { refreshInterval$.next({ value: 0, pause: false }); }); - expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); +}); + +describe('Completed inactivity', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + test('save should be disabled after completed and timeout', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Loading); + + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + storage, + disableSaveAfterSessionCompletesTimeout, + }); + + render( + + + + ); + + await waitFor(() => screen.getByTestId('searchSessionIndicator')); + + await userEvent.click(screen.getByLabelText('Search session loading')); + + expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(5 * 60 * 1000); + }); + + expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + + act(() => { + state$.next(SearchSessionState.Completed); + }); + + expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(2.5 * 60 * 1000); + }); + + expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(2.5 * 60 * 1000); + }); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + }); }); describe('tour steps', () => { @@ -167,8 +260,13 @@ describe('tour steps', () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const rendered = render(); + const rendered = render( + + + + ); await waitFor(() => rendered.getByTestId('searchSessionIndicator')); @@ -199,8 +297,13 @@ describe('tour steps', () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const rendered = render(); + const rendered = render( + + + + ); const searchSessionIndicator = await rendered.findByTestId('searchSessionIndicator'); expect(searchSessionIndicator).toBeTruthy(); @@ -225,8 +328,13 @@ describe('tour steps', () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const rendered = render(); + const rendered = render( + + + + ); await waitFor(() => rendered.getByTestId('searchSessionIndicator')); expect(screen.getByTestId('searchSessionIndicatorPopoverContainer')).toBeInTheDocument(); @@ -242,8 +350,13 @@ describe('tour steps', () => { application: coreStart.application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }); - const rendered = render(); + const rendered = render( + + + + ); await waitFor(() => rendered.getByTestId('searchSessionIndicator')); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index b572db7ebfd4ca..3935b5bb2814b7 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useRef } from 'react'; -import { debounce, distinctUntilChanged, map } from 'rxjs/operators'; -import { timer } from 'rxjs'; +import React, { useCallback, useState } from 'react'; +import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; +import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; import { SearchSessionIndicator, SearchSessionIndicatorRef } from '../search_session_indicator'; @@ -26,6 +26,11 @@ export interface SearchSessionIndicatorDeps { timeFilter: TimefilterContract; application: ApplicationStart; storage: IStorageWrapper; + /** + * Controls for how long we allow to save a session, + * after the last search in the session has completed + */ + disableSaveAfterSessionCompletesTimeout: number; } export const createConnectedSearchSessionIndicator = ({ @@ -33,6 +38,7 @@ export const createConnectedSearchSessionIndicator = ({ application, timeFilter, storage, + disableSaveAfterSessionCompletesTimeout, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter @@ -43,60 +49,104 @@ export const createConnectedSearchSessionIndicator = ({ debounce((_state) => timer(_state === SearchSessionState.None ? 50 : 300)) // switch to None faster to quickly remove indicator when navigating away ); + const disableSaveAfterSessionCompleteTimedOut$ = sessionService.state$.pipe( + switchMap((_state) => + _state === SearchSessionState.Completed + ? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true))) + : of(false) + ), + distinctUntilChanged() + ); + return () => { - const ref = useRef(null); const state = useObservable(debouncedSessionServiceState$, SearchSessionState.None); const autoRefreshEnabled = useObservable(isAutoRefreshEnabled$, isAutoRefreshEnabled()); - const isDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled(); + const isSaveDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled(); + const disableSaveAfterSessionCompleteTimedOut = useObservable( + disableSaveAfterSessionCompleteTimedOut$, + false + ); + const [ + searchSessionIndicator, + setSearchSessionIndicator, + ] = useState(null); + const searchSessionIndicatorRef = useCallback((ref: SearchSessionIndicatorRef) => { + if (ref !== null) { + setSearchSessionIndicator(ref); + } + }, []); - let disabled = false; - let disabledReasonText: string = ''; + let saveDisabled = false; + let saveDisabledReasonText: string = ''; if (autoRefreshEnabled) { - disabled = true; - disabledReasonText = i18n.translate( + saveDisabled = true; + saveDisabledReasonText = i18n.translate( 'xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage', { - defaultMessage: 'Search sessions are not available when auto refresh is enabled.', + defaultMessage: 'Saving search session is not available when auto refresh is enabled.', + } + ); + } + + if (disableSaveAfterSessionCompleteTimedOut) { + saveDisabled = true; + saveDisabledReasonText = i18n.translate( + 'xpack.data.searchSessionIndicator.disabledDueToTimeoutMessage', + { + defaultMessage: 'Search session results expired.', } ); } + if (isSaveDisabledByApp.disabled) { + saveDisabled = true; + saveDisabledReasonText = isSaveDisabledByApp.reasonText; + } + const { markOpenedDone, markRestoredDone } = useSearchSessionTour( storage, - ref, + searchSessionIndicator, state, - disabled + saveDisabled ); - if (isDisabledByApp.disabled) { - disabled = true; - disabledReasonText = isDisabledByApp.reasonText; - } + const onOpened = useCallback( + (openedState: SearchSessionState) => { + markOpenedDone(); + if (openedState === SearchSessionState.Restored) { + markRestoredDone(); + } + }, + [markOpenedDone, markRestoredDone] + ); + + const onContinueInBackground = useCallback(() => { + if (saveDisabled) return; + sessionService.save(); + }, [saveDisabled]); + + const onSaveResults = useCallback(() => { + if (saveDisabled) return; + sessionService.save(); + }, [saveDisabled]); + + const onCancel = useCallback(() => { + sessionService.cancel(); + }, []); if (!sessionService.isSessionStorageReady()) return null; return ( { - sessionService.save(); - }} - onSaveResults={() => { - sessionService.save(); - }} - onCancel={() => { - sessionService.cancel(); - }} - disabled={disabled} - disabledReasonText={disabledReasonText} - onOpened={(openedState) => { - markOpenedDone(); - if (openedState === SearchSessionState.Restored) { - markRestoredDone(); - } - }} + saveDisabled={saveDisabled} + saveDisabledReasonText={saveDisabledReasonText} + onContinueInBackground={onContinueInBackground} + onSaveResults={onSaveResults} + onCancel={onCancel} + onOpened={onOpened} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx index 8c04410f9953bf..7987278f400ff9 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { MutableRefObject, useCallback, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { SearchSessionIndicatorRef } from '../search_session_indicator'; import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; @@ -16,7 +16,7 @@ export const TOUR_RESTORE_STEP_KEY = `data.searchSession.tour.restore`; export function useSearchSessionTour( storage: IStorageWrapper, - searchSessionIndicatorRef: MutableRefObject, + searchSessionIndicatorRef: SearchSessionIndicatorRef | null, state: SearchSessionState, searchSessionsDisabled: boolean ) { @@ -30,19 +30,20 @@ export function useSearchSessionTour( useEffect(() => { if (searchSessionsDisabled) return; + if (!searchSessionIndicatorRef) return; let timeoutHandle: number; if (state === SearchSessionState.Loading) { if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { timeoutHandle = window.setTimeout(() => { - safeOpen(searchSessionIndicatorRef); + searchSessionIndicatorRef.openPopover(); }, TOUR_TAKING_TOO_LONG_TIMEOUT); } } if (state === SearchSessionState.Restored) { if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { - safeOpen(searchSessionIndicatorRef); + searchSessionIndicatorRef.openPopover(); } } @@ -79,15 +80,3 @@ function safeSet(storage: IStorageWrapper, key: string) { return true; } } - -function safeOpen(searchSessionIndicatorRef: MutableRefObject) { - if (searchSessionIndicatorRef.current) { - searchSessionIndicatorRef.current.openPopover(); - } else { - // TODO: needed for initial open when component is not rendered yet - // fix after: https://github.com/elastic/eui/issues/4460 - setTimeout(() => { - searchSessionIndicatorRef.current?.openPopover(); - }, 50); - } -} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx index f2d5a3c52daea6..62d95c1043800d 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx @@ -33,9 +33,9 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => (
diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.test.tsx index 59c39aecddb329..ff9e27cad1869a 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.test.tsx @@ -108,11 +108,21 @@ test('Canceled state', async () => { }); test('Disabled state', async () => { - render( + const { rerender } = render( + + + + ); + + await userEvent.click(screen.getByLabelText('Search session loading')); + + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + + rerender( - + ); - expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); }); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index 9ac537829a670d..eb58039ff58f7d 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -31,8 +31,10 @@ export interface SearchSessionIndicatorProps { onCancel?: () => void; viewSearchSessionsLink?: string; onSaveResults?: () => void; - disabled?: boolean; - disabledReasonText?: string; + + saveDisabled?: boolean; + saveDisabledReasonText?: string; + onOpened?: (openedState: SearchSessionState) => void; } @@ -55,17 +57,22 @@ const CancelButton = ({ onCancel = () => {}, buttonProps = {} }: ActionButtonPro const ContinueInBackgroundButton = ({ onContinueInBackground = () => {}, buttonProps = {}, + saveDisabled = false, + saveDisabledReasonText, }: ActionButtonProps) => ( - - - + + + + + ); const ViewAllSearchSessionsButton = ({ @@ -84,17 +91,25 @@ const ViewAllSearchSessionsButton = ({ ); -const SaveButton = ({ onSaveResults = () => {}, buttonProps = {} }: ActionButtonProps) => ( - - - +const SaveButton = ({ + onSaveResults = () => {}, + buttonProps = {}, + saveDisabled = false, + saveDisabledReasonText, +}: ActionButtonProps) => ( + + + + + ); const searchSessionIndicatorViewStateToProps: { @@ -325,19 +340,16 @@ export const SearchSessionIndicator = React.forwardRef< className="searchSessionIndicator" data-test-subj={'searchSessionIndicator'} data-state={props.state} + data-save-disabled={props.saveDisabled ?? false} panelClassName={'searchSessionIndicator__panel'} repositionOnScroll={true} button={ - + } diff --git a/x-pack/test/send_search_to_background_integration/services/search_sessions.ts b/x-pack/test/send_search_to_background_integration/services/search_sessions.ts index 69b3e05946345a..bf79d35178a60d 100644 --- a/x-pack/test/send_search_to_background_integration/services/search_sessions.ts +++ b/x-pack/test/send_search_to_background_integration/services/search_sessions.ts @@ -47,9 +47,7 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) { public async disabledOrFail() { await this.exists(); - await expect(await (await (await this.find()).findByTagName('button')).isEnabled()).to.be( - false - ); + await expect(await (await this.find()).getAttribute('data-save-disabled')).to.be('true'); } public async expectState(state: SessionStateType) { From 14d41c1952335af4c4b8e93f164939354901bfe9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 8 Feb 2021 07:47:36 -0800 Subject: [PATCH 02/18] [DOCS] More cleanup in developer docs (#90506) --- docs/developer/contributing/development-ci-metrics.asciidoc | 6 ------ .../getting-started/development-plugin-resources.asciidoc | 4 ++-- src/core/CONVENTIONS.md | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/developer/contributing/development-ci-metrics.asciidoc b/docs/developer/contributing/development-ci-metrics.asciidoc index 9c54ef9c8a916c..3e49686fb67f0e 100644 --- a/docs/developer/contributing/development-ci-metrics.asciidoc +++ b/docs/developer/contributing/development-ci-metrics.asciidoc @@ -44,15 +44,9 @@ All metrics are collected from the `tar.gz` archive produced for the linux platf [[ci-metric-distributable-file-count]] `distributable file count` :: The number of files included in the default distributable. -[[ci-metric-oss-distributable-file-count]] `oss distributable file count` :: -The number of files included in the OSS distributable. - [[ci-metric-distributable-size]] `distributable size` :: The size, in bytes, of the default distributable. _(not reported on PRs)_ -[[ci-metric-oss-distributable-size]] `oss distributable size` :: -The size, in bytes, of the OSS distributable. _(not reported on PRs)_ - [[ci-metric-types-saved-object-field-counts]] ==== Saved Object field counts diff --git a/docs/developer/getting-started/development-plugin-resources.asciidoc b/docs/developer/getting-started/development-plugin-resources.asciidoc index 863a67f3c42f04..9aefeabb32a55c 100644 --- a/docs/developer/getting-started/development-plugin-resources.asciidoc +++ b/docs/developer/getting-started/development-plugin-resources.asciidoc @@ -14,8 +14,8 @@ You can use the <> to get a basic structure for a ne {kib} repo should be developed inside the `plugins` folder. If you are building a new plugin to check in to the {kib} repo, you will choose between a few locations: - - {kib-repo}tree/{branch}/x-pack/plugins[x-pack/plugins] for commercially licensed plugins - - {kib-repo}tree/{branch}/src/plugins[src/plugins] for open source licensed plugins + - {kib-repo}tree/{branch}/x-pack/plugins[x-pack/plugins] for plugins related to subscription features + - {kib-repo}tree/{branch}/src/plugins[src/plugins] for plugins related to free features - {kib-repo}tree/{branch}/examples[examples] for developer example plugins (these will not be included in the distributables) [discrete] diff --git a/src/core/CONVENTIONS.md b/src/core/CONVENTIONS.md index a4f50e73f1c57c..56da185d023a95 100644 --- a/src/core/CONVENTIONS.md +++ b/src/core/CONVENTIONS.md @@ -19,10 +19,7 @@ Definition of done for a feature: - has been verified manually by at least one reviewer - can be used by first & third party plugins - there is no contradiction between client and server API -- works for OSS version - - works with and without a `server.basePath` configured - - cannot crash the Kibana server when it fails -- works for the commercial version with a license +- works with the subscription features - for a logged-in user - for anonymous user - compatible with Spaces From d804f4ff760832bc20dc29bc299b4b8c92bef3a5 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 8 Feb 2021 11:14:11 -0500 Subject: [PATCH 03/18] Remove extraneous period (#90214) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/sections/alert_form/alert_notify_when.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx index b6676cfeed140b..ee0f1c4c0ceb8a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx @@ -34,7 +34,7 @@ const NOTIFY_WHEN_OPTIONS: Array> = [ inputDisplay: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.alertNotifyWhen.onActionGroupChange.display', { - defaultMessage: 'Only on status change.', + defaultMessage: 'Only on status change', } ), 'data-test-subj': 'onActionGroupChange', From 4b29e35246208a6e0aff3dcaef15c7331ae7241a Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 8 Feb 2021 11:16:36 -0500 Subject: [PATCH 04/18] [Alerting] Fixing bug with Index Threshold alert when selecting "Of" expression (#90174) * Fixing bug * Updating functional test * Fixing functional test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alert_types/threshold/expression.tsx | 12 ++--- .../public/common/expression_items/of.tsx | 2 + .../common/expression_items/when.test.tsx | 2 + .../public/common/expression_items/when.tsx | 1 + .../alert_create_flyout.ts | 54 ++++++++++++++++--- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx index 4cccd826731245..aed115a53fa260 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx @@ -124,15 +124,13 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< }); if (indexArray.length > 0) { - await refreshEsFields(); + await refreshEsFields(indexArray); } }; - const refreshEsFields = async () => { - if (indexArray.length > 0) { - const currentEsFields = await getFields(http, indexArray); - setEsFields(currentEsFields); - } + const refreshEsFields = async (indices: string[]) => { + const currentEsFields = await getFields(http, indices); + setEsFields(currentEsFields); }; useEffect(() => { @@ -181,7 +179,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< timeField: '', }); } else { - await refreshEsFields(); + await refreshEsFields(indices); } }} onTimeFieldChange={(updatedTimeField: string) => diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx index be54427b90c572..fbc66914559896 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx @@ -91,6 +91,7 @@ export const OfExpression = ({ defaultMessage: 'of', } )} + data-test-subj="ofExpressionPopover" display={display === 'inline' ? 'inline' : 'columns'} value={aggField || firstFieldOption.text} isActive={aggFieldPopoverOpen || !aggField} @@ -119,6 +120,7 @@ export const OfExpression = ({ 0 && aggField !== undefined} error={errors.aggField} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.test.tsx index cde6980e146b2b..d97526d89b62bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.test.tsx @@ -20,6 +20,7 @@ describe('when expression', () => { { { diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index d38ad278d3f64a..6a051cc9fc5e62 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -15,6 +15,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const supertest = getService('supertest'); const find = getService('find'); const retry = getService('retry'); + const comboBox = getService('comboBox'); async function getAlertsByName(name: string) { const { @@ -30,15 +31,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - async function defineAlert(alertName: string, alertType?: string) { - alertType = alertType || '.index-threshold'; + async function defineEsQueryAlert(alertName: string) { await pageObjects.triggersActionsUI.clickCreateAlertButton(); await testSubjects.setValue('alertNameInput', alertName); - await testSubjects.click(`${alertType}-SelectOption`); + await testSubjects.click(`.es-query-SelectOption`); await testSubjects.click('selectIndexExpression'); - const comboBox = await find.byCssSelector('#indexSelectSearchBox'); - await comboBox.click(); - await comboBox.type('k'); + const indexComboBox = await find.byCssSelector('#indexSelectSearchBox'); + await indexComboBox.click(); + await indexComboBox.type('k'); const filterSelectItem = await find.byCssSelector(`.euiFilterSelectItem`); await filterSelectItem.click(); await testSubjects.click('thresholdAlertTimeFieldSelect'); @@ -53,6 +53,44 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.click(); } + async function defineIndexThresholdAlert(alertName: string) { + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await testSubjects.setValue('alertNameInput', alertName); + await testSubjects.click(`.index-threshold-SelectOption`); + await testSubjects.click('selectIndexExpression'); + const indexComboBox = await find.byCssSelector('#indexSelectSearchBox'); + await indexComboBox.click(); + await indexComboBox.type('k'); + const filterSelectItem = await find.byCssSelector(`.euiFilterSelectItem`); + await filterSelectItem.click(); + await testSubjects.click('thresholdAlertTimeFieldSelect'); + await retry.try(async () => { + const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); + expect(fieldOptions[1]).not.to.be(undefined); + await fieldOptions[1].click(); + }); + await testSubjects.click('closePopover'); + // need this two out of popup clicks to close them + const nameInput = await testSubjects.find('alertNameInput'); + await nameInput.click(); + + await testSubjects.click('whenExpression'); + await testSubjects.click('whenExpressionSelect'); + await retry.try(async () => { + const aggTypeOptions = await find.allByCssSelector('#aggTypeField option'); + expect(aggTypeOptions[1]).not.to.be(undefined); + await aggTypeOptions[1].click(); + }); + + await testSubjects.click('ofExpressionPopover'); + const ofComboBox = await find.byCssSelector('#ofField'); + await ofComboBox.click(); + const ofOptionsString = await comboBox.getOptionsList('availablefieldsOptionsComboBox'); + const ofOptions = ofOptionsString.trim().split('\n'); + expect(ofOptions.length > 0).to.be(true); + await comboBox.set('availablefieldsOptionsComboBox', ofOptions[0]); + } + async function defineAlwaysFiringAlert(alertName: string) { await pageObjects.triggersActionsUI.clickCreateAlertButton(); await testSubjects.setValue('alertNameInput', alertName); @@ -67,7 +105,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should create an alert', async () => { const alertName = generateUniqueKey(); - await defineAlert(alertName); + await defineIndexThresholdAlert(alertName); await testSubjects.click('notifyWhenSelect'); await testSubjects.click('onThrottleInterval'); @@ -222,7 +260,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should successfully test valid es_query alert', async () => { const alertName = generateUniqueKey(); - await defineAlert(alertName, '.es-query'); + await defineEsQueryAlert(alertName); // Valid query await testSubjects.setValue('queryJsonEditor', '{"query":{"match_all":{}}}', { From f6a8d6edc472797482c74b9b369d07fb0475bf43 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 8 Feb 2021 18:45:31 +0200 Subject: [PATCH 05/18] [Security Solution][Case] Fix unhandled promise when updating alert status (#90605) --- x-pack/plugins/case/server/client/comments/add.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 5cfa4d70290f09..58d7c9abcbfd3a 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -87,7 +87,7 @@ export const addComment = ({ // If the case is synced with alerts the newly attached alert must match the status of the case. if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { - caseClient.updateAlertsStatus({ + await caseClient.updateAlertsStatus({ ids: [newComment.attributes.alertId], status: myCase.attributes.status, }); From ec672f5df22e16a1a2ba97d38ec04774c3592a57 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 8 Feb 2021 17:48:14 +0100 Subject: [PATCH 06/18] [ML] Handle invalid job ids payload in the Anomaly swim lane (#90597) * [ML] handle invalid job ids payload * [ML] set type for error * [ML] set entire error object --- .../swimlane_input_resolver.test.ts | 36 ++++++++++++++++--- .../swimlane_input_resolver.ts | 16 +++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 2576e5377b39d4..3fffd1588b9b98 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -57,14 +57,17 @@ describe('useSwimlaneInputResolver', () => { ), }, anomalyDetectorService: { - getJobs$: jest.fn(() => - of([ + getJobs$: jest.fn((jobId: string[]) => { + if (jobId.includes('invalid-job-id')) { + throw new Error('Invalid job'); + } + return of([ { job_id: 'cw_multi_1', analysis_config: { bucket_span: '15m' }, }, - ]) - ), + ]); + }), }, } as unknown) as AnomalySwimlaneServices, ]; @@ -128,6 +131,31 @@ describe('useSwimlaneInputResolver', () => { expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(3); }); + + test('should not complete the observable on error', async () => { + const { result } = renderHook(() => + useSwimlaneInputResolver( + embeddableInput as Observable, + onInputChange, + refresh, + services, + 1000, + 1 + ) + ); + + await act(async () => { + embeddableInput.next({ + id: 'test-swimlane-embeddable', + jobIds: ['invalid-job-id'], + swimlaneType: SWIMLANE_TYPE.OVERALL, + filters: [], + query: { language: 'kuery', query: '' }, + } as Partial); + }); + + expect(result.current[6]?.message).toBe('Invalid job'); + }); }); describe('processFilters', () => { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index 5b256b9c5924c2..0d75db64a01b93 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -47,12 +47,17 @@ const FETCH_RESULTS_DEBOUNCE_MS = 500; function getJobsObservable( embeddableInput: Observable, - anomalyDetectorService: AnomalyDetectorService + anomalyDetectorService: AnomalyDetectorService, + setErrorHandler: (e: Error) => void ) { return embeddableInput.pipe( pluck('jobIds'), distinctUntilChanged(isEqual), - switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)) + switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), + catchError((e) => { + setErrorHandler(e.body ?? e); + return of(undefined); + }) ); } @@ -95,7 +100,7 @@ export function useSwimlaneInputResolver( useEffect(() => { const subscription = combineLatest([ - getJobsObservable(embeddableInput, anomalyDetectorService), + getJobsObservable(embeddableInput, anomalyDetectorService, setError), embeddableInput, chartWidth$.pipe(skipWhile((v) => !v)), fromPage$, @@ -112,6 +117,11 @@ export function useSwimlaneInputResolver( tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => { + if (!jobs) { + // couldn't load the list of jobs + return of(undefined); + } + const { viewBy, swimlaneType: swimlaneTypeInput, From bda7b2816f00d288aee774fc3661ed022bd0f270 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Mon, 8 Feb 2021 12:13:55 -0500 Subject: [PATCH 07/18] [Fleet] Cannot delete a managed agent policy (#90505) ## Summary Managed policy cannot be deleted via API or UI closes https://github.com/elastic/kibana/issues/90448 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios #### Manual testing
UI screenshot Screen Shot 2021-02-05 at 1 56 13 PM
API commands ``` ## Create a managed policy curl --user elastic:changeme -X POST localhost:5601/api/fleet/agent_policies -H 'Content-Type: application/json' -d'{ "name": "User created MANAGED", "namespace": "default", "is_managed": true}' -H 'kbn-xsrf: true' {"item":{"id":"17ebd160-67ee-11eb-adb2-f16c6e20580c","name":"User created MANAGED","namespace":"default","is_managed":true,"revision":1,"updated_at":"2021-02-05T20:09:46.614Z","updated_by":"elastic"}} ## Cannot delete it curl --user elastic:changeme -X POST 'http://localhost:5601/api/fleet/agent_policies/delete' -H 'kbn-xsrf: abc' -H 'Content-Type: application/json' --data-raw '{"agentPolicyId": "17ebd160-67ee-11eb-adb2-f16c6e20580c" }' { "statusCode": 400, "error": "Bad Request", "message": "Cannot delete managed policy 17ebd160-67ee-11eb-adb2-f16c6e20580c" } ## Set policy to unmanaged curl --user elastic:changeme -X PUT localhost:5601/api/fleet/agent_policies/17ebd160-67ee-11eb-adb2-f16c6e20580c -H 'Content-Type: application/json' -d'{ "name": "User created MANAGED", "namespace": "default", "is_managed": false}' -H 'kbn-xsrf: true' { "item": { "id": "17ebd160-67ee-11eb-adb2-f16c6e20580c", "name": "User created MANAGED", "namespace": "default", "is_managed": false, "revision": 3, "updated_at": "2021-02-05T20:10:45.383Z", "updated_by": "elastic", "package_policies": [] } } ## Can delete curl --user elastic:changeme -X POST 'http://localhost:5601/api/fleet/agent_policies/delete' -H 'kbn-xsrf: abc' -H 'Content-Type: application/json' --data-raw '{"agentPolicyId": "17ebd160-67ee-11eb-adb2-f16c6e20580c" }' { "id": "17ebd160-67ee-11eb-adb2-f16c6e20580c", "name": "User created MANAGED" } ```
--- x-pack/plugins/fleet/server/errors/index.ts | 1 + .../fleet/server/services/agent_policy.ts | 6 +- .../apis/agent_policy/agent_policy.ts | 93 +++++++++++++++++-- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index a903de01380392..b34568b5fc6afd 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -34,3 +34,4 @@ export class FleetAdminUserInvalidError extends IngestManagerError {} export class ConcurrentInstallOperationError extends IngestManagerError {} export class AgentReassignmentError extends IngestManagerError {} export class AgentUnenrollmentError extends IngestManagerError {} +export class AgentPolicyDeletionError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index ca131efeff68cc..9800ddf95f7b22 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -36,7 +36,7 @@ import { FleetServerPolicy, AGENT_POLICY_INDEX, } from '../../common'; -import { AgentPolicyNameExistsError } from '../errors'; +import { AgentPolicyNameExistsError, AgentPolicyDeletionError } from '../errors'; import { createAgentPolicyAction, listAgents } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; @@ -448,6 +448,10 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } + if (agentPolicy.is_managed) { + throw new AgentPolicyDeletionError(`Cannot delete managed policy ${id}`); + } + const { defaultAgentPolicy: { id: defaultAgentPolicyId }, } = await this.ensureDefaultAgentPolicy(soClient, esClient); diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 9f016ab044a90e..2ba83bff6f1b13 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -38,9 +38,8 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); - const json = getRes.body; - expect(json.item.is_managed).to.equal(false); + const { body } = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + expect(body.item.is_managed).to.equal(false); }); it('sets given is_managed value', async () => { @@ -56,9 +55,25 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); - const json = getRes.body; - expect(json.item.is_managed).to.equal(true); + const { body } = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); + expect(body.item.is_managed).to.equal(true); + + const { + body: { item: createdPolicy2 }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST3', + namespace: 'default', + is_managed: false, + }) + .expect(200); + + const { + body: { item: policy2 }, + } = await supertest.get(`/api/fleet/agent_policies/${createdPolicy2.id}`); + expect(policy2.is_managed).to.equal(false); }); it('should return a 400 with an empty namespace', async () => { @@ -242,6 +257,23 @@ export default function ({ getService }: FtrProviderContext) { const getRes = await supertest.get(`/api/fleet/agent_policies/${createdPolicy.id}`); const json = getRes.body; expect(json.item.is_managed).to.equal(true); + + const { + body: { item: createdPolicy2 }, + } = await supertest + .put(`/api/fleet/agent_policies/${agentPolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'TEST2', + namespace: 'default', + is_managed: false, + }) + .expect(200); + + const { + body: { item: policy2 }, + } = await supertest.get(`/api/fleet/agent_policies/${createdPolicy2.id}`); + expect(policy2.is_managed).to.equal(false); }); it('should return a 409 if policy already exists with name given', async () => { @@ -276,5 +308,54 @@ export default function ({ getService }: FtrProviderContext) { expect(body.message).to.match(/already exists?/); }); }); + + describe('POST /api/fleet/agent_policies/delete', () => { + let managedPolicy: any | undefined; + it('should prevent managed policies being deleted', async () => { + const { + body: { item: createdPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Managed policy', + namespace: 'default', + is_managed: true, + }) + .expect(200); + managedPolicy = createdPolicy; + const { body } = await supertest + .post('/api/fleet/agent_policies/delete') + .set('kbn-xsrf', 'xxx') + .send({ agentPolicyId: managedPolicy.id }) + .expect(400); + + expect(body.message).to.contain('Cannot delete managed policy'); + }); + + it('should allow unmanaged policies being deleted', async () => { + const { + body: { item: unmanagedPolicy }, + } = await supertest + .put(`/api/fleet/agent_policies/${managedPolicy.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Unmanaged policy', + namespace: 'default', + is_managed: false, + }) + .expect(200); + + const { body } = await supertest + .post('/api/fleet/agent_policies/delete') + .set('kbn-xsrf', 'xxx') + .send({ agentPolicyId: unmanagedPolicy.id }); + + expect(body).to.eql({ + id: unmanagedPolicy.id, + name: 'Unmanaged policy', + }); + }); + }); }); } From c306a444f5550faee08d40612386e52731fc657f Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Mon, 8 Feb 2021 18:22:30 +0100 Subject: [PATCH 08/18] [EPM] Conditionally generate ES index pattern name based on dataset_is_prefix (#89870) * Explicitly generate ES index pattern name. * Adjust tests. * Adjust and reenable tests. * Set template priority based on dataset_is_prefix * Refactor indexPatternName -> templateIndexPattern * Add unit tests. * Use more realistic index pattern in test. * Fix unit test. * Add unit test for installTemplate(). Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/fleet/common/types/models/epm.ts | 1 + .../elasticsearch/template/install.test.ts | 110 ++++++++++++++++++ .../epm/elasticsearch/template/install.ts | 14 ++- .../elasticsearch/template/template.test.ts | 99 +++++++++++++--- .../epm/elasticsearch/template/template.ts | 55 +++++++-- .../fleet_api_integration/apis/epm/index.js | 2 +- .../apis/epm/template.ts | 21 +++- 7 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 0f59befc2e4673..e7e5a931b7429f 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -221,6 +221,7 @@ export interface RegistryDataStream { path: string; ingest_pipeline: string; elasticsearch?: RegistryElasticsearch; + dataset_is_prefix?: boolean; } export interface RegistryElasticsearch { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts new file mode 100644 index 00000000000000..be9213aff360d0 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.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 { RegistryDataStream } from '../../../../types'; +import { Field } from '../../fields/field'; + +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { installTemplate } from './install'; + +test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { + const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + const fields: Field[] = []; + const dataStreamDatasetIsPrefixUnset = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + } as RegistryDataStream; + const pkg = { + name: 'package', + version: '0.0.1', + }; + const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; + const templatePriorityDatasetIsPrefixUnset = 200; + await installTemplate({ + callCluster, + fields, + dataStream: dataStreamDatasetIsPrefixUnset, + packageVersion: pkg.version, + packageName: pkg.name, + }); + // @ts-ignore + const sentTemplate = callCluster.mock.calls[0][1].body; + expect(sentTemplate).toBeDefined(); + expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); + expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); +}); + +test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { + const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + const fields: Field[] = []; + const dataStreamDatasetIsPrefixFalse = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + dataset_is_prefix: false, + } as RegistryDataStream; + const pkg = { + name: 'package', + version: '0.0.1', + }; + const templateIndexPatternDatasetIsPrefixFalse = 'metrics-package.dataset-*'; + const templatePriorityDatasetIsPrefixFalse = 200; + await installTemplate({ + callCluster, + fields, + dataStream: dataStreamDatasetIsPrefixFalse, + packageVersion: pkg.version, + packageName: pkg.name, + }); + // @ts-ignore + const sentTemplate = callCluster.mock.calls[0][1].body; + expect(sentTemplate).toBeDefined(); + expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixFalse); + expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); +}); + +test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { + const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + const fields: Field[] = []; + const dataStreamDatasetIsPrefixTrue = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + dataset_is_prefix: true, + } as RegistryDataStream; + const pkg = { + name: 'package', + version: '0.0.1', + }; + const templateIndexPatternDatasetIsPrefixTrue = 'metrics-package.dataset.*-*'; + const templatePriorityDatasetIsPrefixTrue = 150; + await installTemplate({ + callCluster, + fields, + dataStream: dataStreamDatasetIsPrefixTrue, + packageVersion: pkg.version, + packageName: pkg.name, + }); + // @ts-ignore + const sentTemplate = callCluster.mock.calls[0][1].body; + expect(sentTemplate).toBeDefined(); + expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixTrue); + expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 10e94d93bbc8e2..f5f1b4bea788d4 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -17,7 +17,13 @@ import { import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; -import { generateMappings, generateTemplateName, getTemplate } from './template'; +import { + generateMappings, + generateTemplateName, + generateTemplateIndexPattern, + getTemplate, + getTemplatePriority, +} from './template'; import { getAsset, getPathParts } from '../../archive'; import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; @@ -293,6 +299,9 @@ export async function installTemplate({ }): Promise { const mappings = generateMappings(processFields(fields)); const templateName = generateTemplateName(dataStream); + const templateIndexPattern = generateTemplateIndexPattern(dataStream); + const templatePriority = getTemplatePriority(dataStream); + let pipelineName; if (dataStream.ingest_pipeline) { pipelineName = getPipelineNameForInstallation({ @@ -310,11 +319,12 @@ export async function installTemplate({ const template = getTemplate({ type: dataStream.type, - templateName, + templateIndexPattern, mappings, pipelineName, packageName, composedOfTemplates, + templatePriority, ilmPolicy: dataStream.ilm_policy, hidden: dataStream.hidden, }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 80386a2a0dd56c..a176805307845c 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -8,8 +8,14 @@ import { readFileSync } from 'fs'; import { safeLoad } from 'js-yaml'; import path from 'path'; +import { RegistryDataStream } from '../../../../types'; import { Field, processFields } from '../../fields/field'; -import { generateMappings, getTemplate } from './template'; +import { + generateMappings, + getTemplate, + getTemplatePriority, + generateTemplateIndexPattern, +} from './template'; // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ @@ -23,16 +29,17 @@ expect.addSnapshotSerializer({ }); test('get template', () => { - const templateName = 'logs-nginx-access-abcd'; + const templateIndexPattern = 'logs-nginx.access-abcd-*'; const template = getTemplate({ type: 'logs', - templateName, + templateIndexPattern, packageName: 'nginx', mappings: { properties: {} }, composedOfTemplates: [], + templatePriority: 200, }); - expect(template.index_patterns).toStrictEqual([`${templateName}-*`]); + expect(template.index_patterns).toStrictEqual([templateIndexPattern]); }); test('adds composed_of correctly', () => { @@ -40,10 +47,11 @@ test('adds composed_of correctly', () => { const template = getTemplate({ type: 'logs', - templateName: 'name', + templateIndexPattern: 'name-*', packageName: 'nginx', mappings: { properties: {} }, composedOfTemplates, + templatePriority: 200, }); expect(template.composed_of).toStrictEqual(composedOfTemplates); }); @@ -53,35 +61,36 @@ test('adds empty composed_of correctly', () => { const template = getTemplate({ type: 'logs', - templateName: 'name', + templateIndexPattern: 'name-*', packageName: 'nginx', mappings: { properties: {} }, composedOfTemplates, + templatePriority: 200, }); expect(template.composed_of).toStrictEqual(composedOfTemplates); }); test('adds hidden field correctly', () => { - const templateWithHiddenName = 'logs-nginx-access-abcd'; + const templateIndexPattern = 'logs-nginx.access-abcd-*'; const templateWithHidden = getTemplate({ type: 'logs', - templateName: templateWithHiddenName, + templateIndexPattern, packageName: 'nginx', mappings: { properties: {} }, composedOfTemplates: [], + templatePriority: 200, hidden: true, }); expect(templateWithHidden.data_stream.hidden).toEqual(true); - const templateWithoutHiddenName = 'logs-nginx-access-efgh'; - const templateWithoutHidden = getTemplate({ type: 'logs', - templateName: templateWithoutHiddenName, + templateIndexPattern, packageName: 'nginx', mappings: { properties: {} }, composedOfTemplates: [], + templatePriority: 200, }); expect(templateWithoutHidden.data_stream.hidden).toEqual(undefined); }); @@ -95,10 +104,11 @@ test('tests loading base.yml', () => { const mappings = generateMappings(processedFields); const template = getTemplate({ type: 'logs', - templateName: 'foo', + templateIndexPattern: 'foo-*', packageName: 'nginx', mappings, composedOfTemplates: [], + templatePriority: 200, }); expect(template).toMatchSnapshot(path.basename(ymlPath)); @@ -113,10 +123,11 @@ test('tests loading coredns.logs.yml', () => { const mappings = generateMappings(processedFields); const template = getTemplate({ type: 'logs', - templateName: 'foo', + templateIndexPattern: 'foo-*', packageName: 'coredns', mappings, composedOfTemplates: [], + templatePriority: 200, }); expect(template).toMatchSnapshot(path.basename(ymlPath)); @@ -131,10 +142,11 @@ test('tests loading system.yml', () => { const mappings = generateMappings(processedFields); const template = getTemplate({ type: 'metrics', - templateName: 'whatsthis', + templateIndexPattern: 'whatsthis-*', packageName: 'system', mappings, composedOfTemplates: [], + templatePriority: 200, }); expect(template).toMatchSnapshot(path.basename(ymlPath)); @@ -520,3 +532,62 @@ test('tests constant_keyword field type handling', () => { const mappings = generateMappings(processedFields); expect(JSON.stringify(mappings)).toEqual(JSON.stringify(constantKeywordMapping)); }); + +test('tests priority and index pattern for data stream without dataset_is_prefix', () => { + const dataStreamDatasetIsPrefixUnset = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + } as RegistryDataStream; + const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; + const templatePriorityDatasetIsPrefixUnset = 200; + const templateIndexPattern = generateTemplateIndexPattern(dataStreamDatasetIsPrefixUnset); + const templatePriority = getTemplatePriority(dataStreamDatasetIsPrefixUnset); + + expect(templateIndexPattern).toEqual(templateIndexPatternDatasetIsPrefixUnset); + expect(templatePriority).toEqual(templatePriorityDatasetIsPrefixUnset); +}); + +test('tests priority and index pattern for data stream with dataset_is_prefix set to false', () => { + const dataStreamDatasetIsPrefixFalse = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + dataset_is_prefix: false, + } as RegistryDataStream; + const templateIndexPatternDatasetIsPrefixFalse = 'metrics-package.dataset-*'; + const templatePriorityDatasetIsPrefixFalse = 200; + const templateIndexPattern = generateTemplateIndexPattern(dataStreamDatasetIsPrefixFalse); + const templatePriority = getTemplatePriority(dataStreamDatasetIsPrefixFalse); + + expect(templateIndexPattern).toEqual(templateIndexPatternDatasetIsPrefixFalse); + expect(templatePriority).toEqual(templatePriorityDatasetIsPrefixFalse); +}); + +test('tests priority and index pattern for data stream with dataset_is_prefix set to true', () => { + const dataStreamDatasetIsPrefixTrue = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + dataset_is_prefix: true, + } as RegistryDataStream; + const templateIndexPatternDatasetIsPrefixTrue = 'metrics-package.dataset.*-*'; + const templatePriorityDatasetIsPrefixTrue = 150; + const templateIndexPattern = generateTemplateIndexPattern(dataStreamDatasetIsPrefixTrue); + const templatePriority = getTemplatePriority(dataStreamDatasetIsPrefixTrue); + + expect(templateIndexPattern).toEqual(templateIndexPatternDatasetIsPrefixTrue); + expect(templatePriority).toEqual(templatePriorityDatasetIsPrefixTrue); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index ea0bb5dc53a1e4..b86c989f8c24c8 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -33,6 +33,10 @@ export interface CurrentDataStream { const DEFAULT_SCALING_FACTOR = 1000; const DEFAULT_IGNORE_ABOVE = 1024; +// see discussion in https://github.com/elastic/kibana/issues/88307 +const DEFAULT_TEMPLATE_PRIORITY = 200; +const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150; + /** * getTemplate retrieves the default template but overwrites the index pattern with the given value. * @@ -40,29 +44,32 @@ const DEFAULT_IGNORE_ABOVE = 1024; */ export function getTemplate({ type, - templateName, + templateIndexPattern, mappings, pipelineName, packageName, composedOfTemplates, + templatePriority, ilmPolicy, hidden, }: { type: string; - templateName: string; + templateIndexPattern: string; mappings: IndexTemplateMappings; pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; + templatePriority: number; ilmPolicy?: string | undefined; hidden?: boolean; }): IndexTemplate { const template = getBaseTemplate( type, - templateName, + templateIndexPattern, mappings, packageName, composedOfTemplates, + templatePriority, ilmPolicy, hidden ); @@ -242,6 +249,35 @@ export function generateTemplateName(dataStream: RegistryDataStream): string { return getRegistryDataStreamAssetBaseName(dataStream); } +export function generateTemplateIndexPattern(dataStream: RegistryDataStream): string { + // undefined or explicitly set to false + // See also https://github.com/elastic/package-spec/pull/102 + if (!dataStream.dataset_is_prefix) { + return getRegistryDataStreamAssetBaseName(dataStream) + '-*'; + } else { + return getRegistryDataStreamAssetBaseName(dataStream) + '.*-*'; + } +} + +// Template priorities are discussed in https://github.com/elastic/kibana/issues/88307 +// See also https://www.elastic.co/guide/en/elasticsearch/reference/current/index-templates.html +// +// Built-in templates like logs-*-* and metrics-*-* have priority 100 +// +// EPM generated templates for data streams have priority 200 (DEFAULT_TEMPLATE_PRIORITY) +// +// EPM generated templates for data streams with dataset_is_prefix: true have priority 150 (DATASET_IS_PREFIX_TEMPLATE_PRIORITY) + +export function getTemplatePriority(dataStream: RegistryDataStream): number { + // undefined or explicitly set to false + // See also https://github.com/elastic/package-spec/pull/102 + if (!dataStream.dataset_is_prefix) { + return DEFAULT_TEMPLATE_PRIORITY; + } else { + return DATASET_IS_PREFIX_TEMPLATE_PRIORITY; + } +} + /** * Returns a map of the data stream path fields to elasticsearch index pattern. * @param dataStreams an array of RegistryDataStream objects @@ -255,17 +291,18 @@ export function generateESIndexPatterns( const patterns: Record = {}; for (const dataStream of dataStreams) { - patterns[dataStream.path] = generateTemplateName(dataStream) + '-*'; + patterns[dataStream.path] = generateTemplateIndexPattern(dataStream); } return patterns; } function getBaseTemplate( type: string, - templateName: string, + templateIndexPattern: string, mappings: IndexTemplateMappings, packageName: string, composedOfTemplates: string[], + templatePriority: number, ilmPolicy?: string | undefined, hidden?: boolean ): IndexTemplate { @@ -279,13 +316,9 @@ function getBaseTemplate( }; return { - // This takes precedence over all index templates installed by ES by default (logs-*-* and metrics-*-*) - // if this number is lower than the ES value (which is 100) this template will never be applied when a data stream - // is created. I'm using 200 here to give some room for users to create their own template and fit it between the - // default and the one the ingest manager uses. - priority: 200, + priority: templatePriority, // To be completed with the correct index patterns - index_patterns: [`${templateName}-*`], + index_patterns: [templateIndexPattern], template: { settings: { index: { diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 23b7464a317e90..0020e6bdf1bb01 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -11,7 +11,7 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./setup')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./file')); - //loadTestFile(require.resolve('./template')); + loadTestFile(require.resolve('./template')); loadTestFile(require.resolve('./ilm')); loadTestFile(require.resolve('./install_by_upload')); loadTestFile(require.resolve('./install_overrides')); diff --git a/x-pack/test/fleet_api_integration/apis/epm/template.ts b/x-pack/test/fleet_api_integration/apis/epm/template.ts index c7e9e211552578..d79452ca0eb6f2 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/template.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/template.ts @@ -10,8 +10,8 @@ import { FtrProviderContext } from '../../../api_integration/ftr_provider_contex import { getTemplate } from '../../../../plugins/fleet/server/services/epm/elasticsearch/template/template'; export default function ({ getService }: FtrProviderContext) { - const indexPattern = 'foo'; const templateName = 'bar'; + const templateIndexPattern = 'bar-*'; const es = getService('es'); const mappings = { properties: { @@ -25,27 +25,36 @@ export default function ({ getService }: FtrProviderContext) { it('can be loaded', async () => { const template = getTemplate({ type: 'logs', - templateName, + templateIndexPattern, mappings, packageName: 'system', composedOfTemplates: [], + templatePriority: 200, }); // This test is not an API integration test with Kibana // We want to test here if the template is valid and for this we need a running ES instance. // If the ES instance takes the template, we assume it is a valid template. - const { body: response1 } = await es.indices.putTemplate({ - name: templateName, + const { body: response1 } = await es.transport.request({ + method: 'PUT', + path: `/_index_template/${templateName}`, body: template, }); + // Checks if template loading worked as expected expect(response1).to.eql({ acknowledged: true }); - const { body: response2 } = await es.indices.getTemplate({ name: templateName }); + const { body: response2 } = await es.transport.request({ + method: 'GET', + path: `/_index_template/${templateName}`, + }); + // Checks if the content of the template that was loaded is as expected // We already know based on the above test that the template was valid // but we check here also if we wrote the index pattern inside the template as expected - expect(response2[templateName].index_patterns).to.eql([`${indexPattern}-*`]); + expect(response2.index_templates[0].index_template.index_patterns).to.eql([ + templateIndexPattern, + ]); }); }); } From a0ce7b5aa887d34a7a892553c66f25d72e38d827 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 8 Feb 2021 09:47:55 -0800 Subject: [PATCH 09/18] [kbn/optimizer][ci-stats] ship metrics separate from build (#90482) Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 + .../src/ci_stats_reporter/index.ts | 1 + .../ci_stats_reporter/ship_ci_stats_cli.ts | 48 +++++++ packages/kbn-optimizer/src/cli.ts | 17 +-- .../kbn-optimizer/src/common/bundle.test.ts | 2 + packages/kbn-optimizer/src/common/bundle.ts | 16 ++- .../src/common/bundle_cache.test.ts | 14 +- .../kbn-optimizer/src/common/bundle_cache.ts | 22 ++- packages/kbn-optimizer/src/index.ts | 1 - .../basic_optimization.test.ts.snap | 35 ++++- .../basic_optimization.test.ts | 15 +- packages/kbn-optimizer/src/limits.ts | 21 ++- .../src/optimizer/get_output_stats.ts | 118 ---------------- .../src/optimizer/get_plugin_bundles.test.ts | 10 +- .../src/optimizer/get_plugin_bundles.ts | 5 +- packages/kbn-optimizer/src/optimizer/index.ts | 1 - .../src/optimizer/optimizer_config.test.ts | 8 +- .../src/optimizer/optimizer_config.ts | 10 +- .../src/report_optimizer_stats.ts | 46 ------ .../src/worker/bundle_metrics_plugin.ts | 108 ++++++++++++++ .../src/worker/emit_stats_plugin.ts | 34 +++++ .../worker/populate_bundle_cache_plugin.ts | 132 ++++++++++++++++++ .../kbn-optimizer/src/worker/run_compilers.ts | 122 +--------------- .../src/worker/webpack.config.ts | 6 + .../src/integration_tests/build.test.ts | 3 +- .../kbn-plugin-helpers/src/tasks/optimize.ts | 8 +- scripts/ship_ci_stats.js | 10 ++ .../tasks/build_kibana_platform_plugins.ts | 39 ++++-- test/scripts/jenkins_baseline.sh | 4 + test/scripts/jenkins_build_kibana.sh | 3 + test/scripts/jenkins_xpack_baseline.sh | 4 + test/scripts/jenkins_xpack_build_kibana.sh | 4 + yarn.lock | 2 +- 33 files changed, 518 insertions(+), 353 deletions(-) create mode 100644 packages/kbn-dev-utils/src/ci_stats_reporter/ship_ci_stats_cli.ts delete mode 100644 packages/kbn-optimizer/src/optimizer/get_output_stats.ts delete mode 100644 packages/kbn-optimizer/src/report_optimizer_stats.ts create mode 100644 packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts create mode 100644 packages/kbn-optimizer/src/worker/emit_stats_plugin.ts create mode 100644 packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts create mode 100644 scripts/ship_ci_stats.js diff --git a/package.json b/package.json index b224f0c1ae0d5b..7144745f2ae358 100644 --- a/package.json +++ b/package.json @@ -558,6 +558,7 @@ "@types/webpack": "^4.41.3", "@types/webpack-env": "^1.15.3", "@types/webpack-merge": "^4.1.5", + "@types/webpack-sources": "^0.1.4", "@types/write-pkg": "^3.1.0", "@types/xml-crypto": "^1.4.1", "@types/xml2js": "^0.4.5", @@ -843,6 +844,7 @@ "webpack-cli": "^3.3.12", "webpack-dev-server": "^3.11.0", "webpack-merge": "^4.2.2", + "webpack-sources": "^1.4.1", "write-pkg": "^4.0.0", "xml-crypto": "^2.0.0", "xmlbuilder": "13.0.2", diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts index 165239cbebb89d..d99217c38b410a 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts @@ -7,3 +7,4 @@ */ export * from './ci_stats_reporter'; +export * from './ship_ci_stats_cli'; diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ship_ci_stats_cli.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ship_ci_stats_cli.ts new file mode 100644 index 00000000000000..244af7b6574183 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ship_ci_stats_cli.ts @@ -0,0 +1,48 @@ +/* + * 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 Path from 'path'; +import Fs from 'fs'; + +import { CiStatsReporter } from './ci_stats_reporter'; +import { run, createFlagError } from '../run'; + +export function shipCiStatsCli() { + run( + async ({ log, flags }) => { + let metricPaths = flags.metrics; + if (typeof metricPaths === 'string') { + metricPaths = [metricPaths]; + } else if (!Array.isArray(metricPaths) || !metricPaths.every((p) => typeof p === 'string')) { + throw createFlagError('expected --metrics to be a string'); + } + + const reporter = CiStatsReporter.fromEnv(log); + for (const path of metricPaths) { + // resolve path from CLI relative to CWD + const abs = Path.resolve(path); + const json = Fs.readFileSync(abs, 'utf8'); + await reporter.metrics(JSON.parse(json)); + log.success('shipped metrics from', path); + } + }, + { + description: 'ship ci-stats which have been written to files', + usage: `node scripts/ship_ci_stats`, + log: { + defaultLevel: 'debug', + }, + flags: { + string: ['metrics'], + help: ` + --metrics [path] A path to a JSON file that includes metrics which should be sent. Multiple instances supported + `, + }, + } + ); +} diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index 3021982b8ed6a6..8fb906aa4603e8 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -12,11 +12,10 @@ import Path from 'path'; import { REPO_ROOT } from '@kbn/utils'; import { lastValueFrom } from '@kbn/std'; -import { run, createFlagError, CiStatsReporter } from '@kbn/dev-utils'; +import { run, createFlagError } from '@kbn/dev-utils'; import { logOptimizerState } from './log_optimizer_state'; import { OptimizerConfig } from './optimizer'; -import { reportOptimizerStats } from './report_optimizer_stats'; import { runOptimizer } from './run_optimizer'; import { validateLimitsForAllBundles, updateBundleLimits } from './limits'; @@ -120,17 +119,7 @@ run( return; } - let update$ = runOptimizer(config); - - if (reportStats) { - const reporter = CiStatsReporter.fromEnv(log); - - if (!reporter.isEnabled()) { - log.warning('Unable to initialize CiStatsReporter from env'); - } - - update$ = update$.pipe(reportOptimizerStats(reporter, config, log)); - } + const update$ = runOptimizer(config); await lastValueFrom(update$.pipe(logOptimizerState(log, config))); @@ -153,7 +142,6 @@ run( 'cache', 'profile', 'inspect-workers', - 'report-stats', 'validate-limits', 'update-limits', ], @@ -179,7 +167,6 @@ run( --dist create bundles that are suitable for inclusion in the Kibana distributable, enabled when running with --update-limits --scan-dir add a directory to the list of directories scanned for plugins (specify as many times as necessary) --no-inspect-workers when inspecting the parent process, don't inspect the workers - --report-stats attempt to report stats about this execution of the build to the kibana-ci-stats service using this name --validate-limits validate the limits.yml config to ensure that there are limits defined for every bundle --update-limits run a build and rewrite the limits file to include the current bundle sizes +5kb `, diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index b6d25f69e58b4d..ff9aa6fd906280 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -42,6 +42,7 @@ it('creates cache keys', () => { "id": "bar", "manifestPath": undefined, "outputDir": "/foo/bar/target", + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -79,6 +80,7 @@ it('parses bundles from JSON specs', () => { "id": "bar", "manifestPath": undefined, "outputDir": "/foo/bar/target", + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index cb6096759739bf..64b44de0dd1b3e 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -36,6 +36,8 @@ export interface BundleSpec { readonly banner?: string; /** Absolute path to a kibana.json manifest file, if omitted we assume there are not dependenices */ readonly manifestPath?: string; + /** Maximum allowed page load asset size for the bundles page load asset */ + readonly pageLoadAssetSizeLimit?: number; } export class Bundle { @@ -63,6 +65,8 @@ export class Bundle { * Every bundle mentioned in the `requiredBundles` must be built together. */ public readonly manifestPath: BundleSpec['manifestPath']; + /** Maximum allowed page load asset size for the bundles page load asset */ + public readonly pageLoadAssetSizeLimit: BundleSpec['pageLoadAssetSizeLimit']; public readonly cache: BundleCache; @@ -75,8 +79,9 @@ export class Bundle { this.outputDir = spec.outputDir; this.manifestPath = spec.manifestPath; this.banner = spec.banner; + this.pageLoadAssetSizeLimit = spec.pageLoadAssetSizeLimit; - this.cache = new BundleCache(Path.resolve(this.outputDir, '.kbn-optimizer-cache')); + this.cache = new BundleCache(this.outputDir); } /** @@ -107,6 +112,7 @@ export class Bundle { outputDir: this.outputDir, manifestPath: this.manifestPath, banner: this.banner, + pageLoadAssetSizeLimit: this.pageLoadAssetSizeLimit, }; } @@ -222,6 +228,13 @@ export function parseBundles(json: string) { } } + const { pageLoadAssetSizeLimit } = spec; + if (pageLoadAssetSizeLimit !== undefined) { + if (!(typeof pageLoadAssetSizeLimit === 'number')) { + throw new Error('`bundles[]` must have a numeric `pageLoadAssetSizeLimit` property'); + } + } + return new Bundle({ type, id, @@ -231,6 +244,7 @@ export function parseBundles(json: string) { outputDir, banner, manifestPath, + pageLoadAssetSizeLimit, }); } ); diff --git a/packages/kbn-optimizer/src/common/bundle_cache.test.ts b/packages/kbn-optimizer/src/common/bundle_cache.test.ts index 82a8c0debb83c4..e903a687908b9f 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.test.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.test.ts @@ -25,12 +25,12 @@ beforeEach(() => { }); it(`doesn't complain if files are not on disk`, () => { - const cache = new BundleCache('/foo/bar.json'); + const cache = new BundleCache('/foo'); expect(cache.get()).toEqual({}); }); it(`updates files on disk when calling set()`, () => { - const cache = new BundleCache('/foo/bar.json'); + const cache = new BundleCache('/foo'); cache.set(SOME_STATE); expect(mockReadFileSync).not.toHaveBeenCalled(); expect(mockMkdirSync.mock.calls).toMatchInlineSnapshot(` @@ -46,7 +46,7 @@ it(`updates files on disk when calling set()`, () => { expect(mockWriteFileSync.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/foo/bar.json", + "/foo/.kbn-optimizer-cache", "{ \\"cacheKey\\": \\"abc\\", \\"files\\": [ @@ -61,7 +61,7 @@ it(`updates files on disk when calling set()`, () => { }); it(`serves updated state from memory`, () => { - const cache = new BundleCache('/foo/bar.json'); + const cache = new BundleCache('/foo'); cache.set(SOME_STATE); jest.clearAllMocks(); @@ -72,7 +72,7 @@ it(`serves updated state from memory`, () => { }); it('reads state from disk on get() after refresh()', () => { - const cache = new BundleCache('/foo/bar.json'); + const cache = new BundleCache('/foo'); cache.set(SOME_STATE); cache.refresh(); jest.clearAllMocks(); @@ -83,7 +83,7 @@ it('reads state from disk on get() after refresh()', () => { expect(mockReadFileSync.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/foo/bar.json", + "/foo/.kbn-optimizer-cache", "utf8", ], ] @@ -91,7 +91,7 @@ it('reads state from disk on get() after refresh()', () => { }); it('provides accessors to specific state properties', () => { - const cache = new BundleCache('/foo/bar.json'); + const cache = new BundleCache('/foo'); expect(cache.getModuleCount()).toBe(undefined); expect(cache.getReferencedFiles()).toEqual(undefined); diff --git a/packages/kbn-optimizer/src/common/bundle_cache.ts b/packages/kbn-optimizer/src/common/bundle_cache.ts index 39b52095c819a5..7c0770caa26235 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.ts @@ -9,6 +9,9 @@ import Fs from 'fs'; import Path from 'path'; +import webpack from 'webpack'; +import { RawSource } from 'webpack-sources'; + export interface State { optimizerCacheKey?: unknown; cacheKey?: unknown; @@ -20,13 +23,17 @@ export interface State { const DEFAULT_STATE: State = {}; const DEFAULT_STATE_JSON = JSON.stringify(DEFAULT_STATE); +const CACHE_FILENAME = '.kbn-optimizer-cache'; /** * Helper to read and update metadata for bundles. */ export class BundleCache { private state: State | undefined = undefined; - constructor(private readonly path: string | false) {} + private readonly path: string | false; + constructor(outputDir: string | false) { + this.path = outputDir === false ? false : Path.resolve(outputDir, CACHE_FILENAME); + } refresh() { this.state = undefined; @@ -63,6 +70,7 @@ export class BundleCache { set(updated: State) { this.state = updated; + if (this.path) { const directory = Path.dirname(this.path); Fs.mkdirSync(directory, { recursive: true }); @@ -107,4 +115,16 @@ export class BundleCache { } } } + + public writeWebpackAsset(compilation: webpack.compilation.Compilation) { + if (!this.path) { + return; + } + + const source = new RawSource(JSON.stringify(this.state, null, 2)); + + // see https://github.com/jantimon/html-webpack-plugin/blob/33d69f49e6e9787796402715d1b9cd59f80b628f/index.js#L266 + // @ts-expect-error undocumented, used to add assets to the output + compilation.emitAsset(CACHE_FILENAME, source); + } } diff --git a/packages/kbn-optimizer/src/index.ts b/packages/kbn-optimizer/src/index.ts index a74679bfff5363..551d2ffacfcfbf 100644 --- a/packages/kbn-optimizer/src/index.ts +++ b/packages/kbn-optimizer/src/index.ts @@ -9,6 +9,5 @@ export { OptimizerConfig } from './optimizer'; export * from './run_optimizer'; export * from './log_optimizer_state'; -export * from './report_optimizer_stats'; export * from './node'; export * from './limits'; diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 1ed1b92f9c2d90..9e9e8960da21bb 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -13,6 +13,7 @@ OptimizerConfig { "id": "bar", "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -29,6 +30,7 @@ OptimizerConfig { "id": "foo", "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -47,6 +49,7 @@ OptimizerConfig { "id": "baz", "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/target/public, + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -57,7 +60,6 @@ OptimizerConfig { "cache": true, "dist": false, "inspectWorkers": false, - "limits": "", "maxWorkerCount": 1, "plugins": Array [ Object { @@ -109,3 +111,34 @@ exports[`prepares assets for distribution: baz bundle 1`] = ` exports[`prepares assets for distribution: foo async bundle 1`] = `"(window[\\"foo_bundle_jsonpfunction\\"]=window[\\"foo_bundle_jsonpfunction\\"]||[]).push([[1],{3:function(module,__webpack_exports__,__webpack_require__){\\"use strict\\";__webpack_require__.r(__webpack_exports__);__webpack_require__.d(__webpack_exports__,\\"foo\\",(function(){return foo}));function foo(){}}}]);"`; exports[`prepares assets for distribution: foo bundle 1`] = `"(function(modules){function webpackJsonpCallback(data){var chunkIds=data[0];var moreModules=data[1];var moduleId,chunkId,i=0,resolves=[];for(;i { dist: false, }); - expect(config.limits).toEqual(readLimits()); - (config as any).limits = ''; - expect(config).toMatchSnapshot('OptimizerConfig'); const msgs = await allValuesFrom( @@ -235,6 +226,10 @@ it('prepares assets for distribution', async () => { await allValuesFrom(runOptimizer(config).pipe(logOptimizerState(log, config))); + expect( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/metrics.json'), 'utf8') + ).toMatchSnapshot('metrics.json'); + expectFileMatchesSnapshotWithCompression('plugins/foo/target/public/foo.plugin.js', 'foo bundle'); expectFileMatchesSnapshotWithCompression( 'plugins/foo/target/public/foo.chunk.1.js', diff --git a/packages/kbn-optimizer/src/limits.ts b/packages/kbn-optimizer/src/limits.ts index fcfd36664c1f40..292314a4608e40 100644 --- a/packages/kbn-optimizer/src/limits.ts +++ b/packages/kbn-optimizer/src/limits.ts @@ -7,12 +7,13 @@ */ import Fs from 'fs'; +import Path from 'path'; import dedent from 'dedent'; import Yaml from 'js-yaml'; -import { createFailError, ToolingLog } from '@kbn/dev-utils'; +import { createFailError, ToolingLog, CiStatsMetrics } from '@kbn/dev-utils'; -import { OptimizerConfig, getMetrics, Limits } from './optimizer'; +import { OptimizerConfig, Limits } from './optimizer'; const LIMITS_PATH = require.resolve('../limits.yml'); const DEFAULT_BUDGET = 15000; @@ -33,7 +34,7 @@ export function readLimits(): Limits { } export function validateLimitsForAllBundles(log: ToolingLog, config: OptimizerConfig) { - const limitBundleIds = Object.keys(config.limits.pageLoadAssetSize || {}); + const limitBundleIds = Object.keys(readLimits().pageLoadAssetSize || {}); const configBundleIds = config.bundles.map((b) => b.id); const missingBundleIds = diff(configBundleIds, limitBundleIds); @@ -75,15 +76,21 @@ interface UpdateBundleLimitsOptions { } export function updateBundleLimits({ log, config, dropMissing }: UpdateBundleLimitsOptions) { - const metrics = getMetrics(log, config); + const limits = readLimits(); + const metrics: CiStatsMetrics = config.bundles + .map((bundle) => + JSON.parse(Fs.readFileSync(Path.resolve(bundle.outputDir, 'metrics.json'), 'utf-8')) + ) + .flat() + .sort((a, b) => a.id.localeCompare(b.id)); const pageLoadAssetSize: NonNullable = dropMissing ? {} - : config.limits.pageLoadAssetSize ?? {}; + : limits.pageLoadAssetSize ?? {}; - for (const metric of metrics.sort((a, b) => a.id.localeCompare(b.id))) { + for (const metric of metrics) { if (metric.group === 'page load bundle size') { - const existingLimit = config.limits.pageLoadAssetSize?.[metric.id]; + const existingLimit = limits.pageLoadAssetSize?.[metric.id]; pageLoadAssetSize[metric.id] = existingLimit != null && existingLimit >= metric.value ? existingLimit diff --git a/packages/kbn-optimizer/src/optimizer/get_output_stats.ts b/packages/kbn-optimizer/src/optimizer/get_output_stats.ts deleted file mode 100644 index e7059c4d6799cf..00000000000000 --- a/packages/kbn-optimizer/src/optimizer/get_output_stats.ts +++ /dev/null @@ -1,118 +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 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 Fs from 'fs'; -import Path from 'path'; - -import { ToolingLog, CiStatsMetrics } from '@kbn/dev-utils'; -import { OptimizerConfig } from './optimizer_config'; - -const flatten = (arr: Array): T[] => - arr.reduce((acc: T[], item) => acc.concat(item), []); - -interface Entry { - relPath: string; - stats: Fs.Stats; -} - -const IGNORED_EXTNAME = ['.map', '.br', '.gz']; - -const getFiles = (dir: string, parent?: string) => - flatten( - Fs.readdirSync(dir).map((name): Entry | Entry[] => { - const absPath = Path.join(dir, name); - const relPath = parent ? Path.join(parent, name) : name; - const stats = Fs.statSync(absPath); - - if (stats.isDirectory()) { - return getFiles(absPath, relPath); - } - - return { - relPath, - stats, - }; - }) - ).filter((file) => { - const filename = Path.basename(file.relPath); - if (filename.startsWith('.')) { - return false; - } - - const ext = Path.extname(filename); - if (IGNORED_EXTNAME.includes(ext)) { - return false; - } - - return true; - }); - -export function getMetrics(log: ToolingLog, config: OptimizerConfig) { - return flatten( - config.bundles.map((bundle) => { - // make the cache read from the cache file since it was likely updated by the worker - bundle.cache.refresh(); - - const outputFiles = getFiles(bundle.outputDir); - const entryName = `${bundle.id}.${bundle.type}.js`; - const entry = outputFiles.find((f) => f.relPath === entryName); - if (!entry) { - throw new Error( - `Unable to find bundle entry named [${entryName}] in [${bundle.outputDir}]` - ); - } - - const chunkPrefix = `${bundle.id}.chunk.`; - const asyncChunks = outputFiles.filter((f) => f.relPath.startsWith(chunkPrefix)); - const miscFiles = outputFiles.filter((f) => f !== entry && !asyncChunks.includes(f)); - - if (asyncChunks.length) { - log.verbose(bundle.id, 'async chunks', asyncChunks); - } - if (miscFiles.length) { - log.verbose(bundle.id, 'misc files', asyncChunks); - } - - const sumSize = (files: Entry[]) => files.reduce((acc: number, f) => acc + f.stats!.size, 0); - - const bundleMetrics: CiStatsMetrics = [ - { - group: `@kbn/optimizer bundle module count`, - id: bundle.id, - value: bundle.cache.getModuleCount() || 0, - }, - { - group: `page load bundle size`, - id: bundle.id, - value: entry.stats!.size, - limit: config.limits.pageLoadAssetSize?.[bundle.id], - limitConfigPath: `packages/kbn-optimizer/limits.yml`, - }, - { - group: `async chunks size`, - id: bundle.id, - value: sumSize(asyncChunks), - }, - { - group: `async chunk count`, - id: bundle.id, - value: asyncChunks.length, - }, - { - group: `miscellaneous assets size`, - id: bundle.id, - value: sumSize(miscFiles), - }, - ]; - - log.debug(bundle.id, 'metrics', bundleMetrics); - - return bundleMetrics; - }) - ); -} diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts index d921d5e5cca313..e4cdddbf56dcb0 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts @@ -48,7 +48,12 @@ it('returns a bundle for core and each plugin', () => { }, ], '/repo', - '/output' + '/output', + { + pageLoadAssetSize: { + box: 123, + }, + } ).map((b) => b.toSpec()) ).toMatchInlineSnapshot(` Array [ @@ -58,6 +63,7 @@ it('returns a bundle for core and each plugin', () => { "id": "foo", "manifestPath": /plugins/foo/kibana.json, "outputDir": /plugins/foo/target/public, + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -70,6 +76,7 @@ it('returns a bundle for core and each plugin', () => { "id": "baz", "manifestPath": /plugins/baz/kibana.json, "outputDir": /plugins/baz/target/public, + "pageLoadAssetSizeLimit": undefined, "publicDirNames": Array [ "public", ], @@ -84,6 +91,7 @@ it('returns a bundle for core and each plugin', () => { "id": "box", "manifestPath": /x-pack/plugins/box/kibana.json, "outputDir": /x-pack/plugins/box/target/public, + "pageLoadAssetSizeLimit": 123, "publicDirNames": Array [ "public", ], diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts index 76a0d51edac82d..8134707561bc0e 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts @@ -9,13 +9,15 @@ import Path from 'path'; import { Bundle } from '../common'; +import { Limits } from './optimizer_config'; import { KibanaPlatformPlugin } from './kibana_platform_plugins'; export function getPluginBundles( plugins: KibanaPlatformPlugin[], repoRoot: string, - outputRoot: string + outputRoot: string, + limits: Limits ) { const xpackDirSlash = Path.resolve(repoRoot, 'x-pack') + Path.sep; @@ -39,6 +41,7 @@ export function getPluginBundles( ? `/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. \n` + ` * Licensed under the Elastic License 2.0; you may not use this file except in compliance with the Elastic License 2.0. */\n` : undefined, + pageLoadAssetSizeLimit: limits.pageLoadAssetSize?.[p.id], }) ); } diff --git a/packages/kbn-optimizer/src/optimizer/index.ts b/packages/kbn-optimizer/src/optimizer/index.ts index ced61463d5edd5..28d206488b0a49 100644 --- a/packages/kbn-optimizer/src/optimizer/index.ts +++ b/packages/kbn-optimizer/src/optimizer/index.ts @@ -14,4 +14,3 @@ export * from './watch_bundles_for_changes'; export * from './run_workers'; export * from './bundle_cache'; export * from './handle_optimizer_completion'; -export * from './get_output_stats'; diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts index 5677719628b6aa..c60d6719cdea78 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts @@ -435,7 +435,6 @@ describe('OptimizerConfig::create()', () => { "cache": Symbol(parsed cache), "dist": Symbol(parsed dist), "inspectWorkers": Symbol(parsed inspect workers), - "limits": Symbol(limits), "maxWorkerCount": Symbol(parsed max worker count), "plugins": Symbol(new platform plugins), "profileWebpack": Symbol(parsed profile webpack), @@ -457,7 +456,7 @@ describe('OptimizerConfig::create()', () => { [Window], ], "invocationCallOrder": Array [ - 21, + 22, ], "results": Array [ Object { @@ -480,7 +479,7 @@ describe('OptimizerConfig::create()', () => { [Window], ], "invocationCallOrder": Array [ - 24, + 25, ], "results": Array [ Object { @@ -498,13 +497,14 @@ describe('OptimizerConfig::create()', () => { Symbol(new platform plugins), Symbol(parsed repo root), Symbol(parsed output root), + Symbol(limits), ], ], "instances": Array [ [Window], ], "invocationCallOrder": Array [ - 22, + 23, ], "results": Array [ Object { diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts index b93d7a753c9acd..ed521d32a0a297 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts @@ -211,6 +211,7 @@ export class OptimizerConfig { } static create(inputOptions: Options) { + const limits = readLimits(); const options = OptimizerConfig.parseOptions(inputOptions); const plugins = findKibanaPlatformPlugins(options.pluginScanDirs, options.pluginPaths); const bundles = [ @@ -223,10 +224,11 @@ export class OptimizerConfig { sourceRoot: options.repoRoot, contextDir: Path.resolve(options.repoRoot, 'src/core'), outputDir: Path.resolve(options.outputRoot, 'src/core/target/public'), + pageLoadAssetSizeLimit: limits.pageLoadAssetSize?.core, }), ] : []), - ...getPluginBundles(plugins, options.repoRoot, options.outputRoot), + ...getPluginBundles(plugins, options.repoRoot, options.outputRoot, limits), ]; return new OptimizerConfig( @@ -239,8 +241,7 @@ export class OptimizerConfig { options.maxWorkerCount, options.dist, options.profileWebpack, - options.themeTags, - readLimits() + options.themeTags ); } @@ -254,8 +255,7 @@ export class OptimizerConfig { public readonly maxWorkerCount: number, public readonly dist: boolean, public readonly profileWebpack: boolean, - public readonly themeTags: ThemeTags, - public readonly limits: Limits + public readonly themeTags: ThemeTags ) {} getWorkerConfig(optimizerCacheKey: unknown): WorkerConfig { diff --git a/packages/kbn-optimizer/src/report_optimizer_stats.ts b/packages/kbn-optimizer/src/report_optimizer_stats.ts deleted file mode 100644 index eeed2fb1b156c8..00000000000000 --- a/packages/kbn-optimizer/src/report_optimizer_stats.ts +++ /dev/null @@ -1,46 +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 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 { materialize, mergeMap, dematerialize } from 'rxjs/operators'; -import { CiStatsReporter, ToolingLog } from '@kbn/dev-utils'; - -import { OptimizerUpdate$ } from './run_optimizer'; -import { OptimizerConfig, getMetrics } from './optimizer'; -import { pipeClosure } from './common'; - -export function reportOptimizerStats( - reporter: CiStatsReporter, - config: OptimizerConfig, - log: ToolingLog -) { - return pipeClosure((update$: OptimizerUpdate$) => - update$.pipe( - materialize(), - mergeMap(async (n) => { - if (n.kind === 'C') { - const metrics = getMetrics(log, config); - - await reporter.metrics(metrics); - - for (const metric of metrics) { - if (metric.limit != null && metric.value > metric.limit) { - const value = metric.value.toLocaleString(); - const limit = metric.limit.toLocaleString(); - log.warning( - `Metric [${metric.group}] for [${metric.id}] of [${value}] over the limit of [${limit}]` - ); - } - } - } - - return n; - }), - dematerialize() - ) - ); -} diff --git a/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts new file mode 100644 index 00000000000000..909a97a3e11c78 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/bundle_metrics_plugin.ts @@ -0,0 +1,108 @@ +/* + * 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 Path from 'path'; + +import webpack from 'webpack'; +import { RawSource } from 'webpack-sources'; +import { CiStatsMetrics } from '@kbn/dev-utils'; + +import { Bundle } from '../common'; + +interface Asset { + name: string; + size: number; +} + +const IGNORED_EXTNAME = ['.map', '.br', '.gz']; + +export class BundleMetricsPlugin { + constructor(private readonly bundle: Bundle) {} + + public apply(compiler: webpack.Compiler) { + const { bundle } = this; + + compiler.hooks.emit.tap('BundleMetricsPlugin', (compilation) => { + const assets = Object.entries(compilation.assets) + .map( + ([name, source]: [string, any]): Asset => ({ + name, + size: source.size(), + }) + ) + .filter((asset) => { + const filename = Path.basename(asset.name); + if (filename.startsWith('.')) { + return false; + } + + const ext = Path.extname(filename); + if (IGNORED_EXTNAME.includes(ext)) { + return false; + } + + return true; + }); + + const entryName = `${bundle.id}.${bundle.type}.js`; + const entry = assets.find((a) => a.name === entryName); + if (!entry) { + throw new Error( + `Unable to find bundle entry named [${entryName}] in [${bundle.outputDir}]` + ); + } + + const chunkPrefix = `${bundle.id}.chunk.`; + const asyncChunks = assets.filter((a) => a.name.startsWith(chunkPrefix)); + const miscFiles = assets.filter((a) => a !== entry && !asyncChunks.includes(a)); + + const sumSize = (files: Asset[]) => files.reduce((acc: number, a) => acc + a.size, 0); + + const moduleCount = bundle.cache.getModuleCount(); + if (moduleCount === undefined) { + throw new Error(`moduleCount wasn't populated by PopulateBundleCachePlugin`); + } + + const bundleMetrics: CiStatsMetrics = [ + { + group: `@kbn/optimizer bundle module count`, + id: bundle.id, + value: moduleCount, + }, + { + group: `page load bundle size`, + id: bundle.id, + value: entry.size, + limit: bundle.pageLoadAssetSizeLimit, + limitConfigPath: `packages/kbn-optimizer/limits.yml`, + }, + { + group: `async chunks size`, + id: bundle.id, + value: sumSize(asyncChunks), + }, + { + group: `async chunk count`, + id: bundle.id, + value: asyncChunks.length, + }, + { + group: `miscellaneous assets size`, + id: bundle.id, + value: sumSize(miscFiles), + }, + ]; + + const metricsSource = new RawSource(JSON.stringify(bundleMetrics, null, 2)); + + // see https://github.com/jantimon/html-webpack-plugin/blob/33d69f49e6e9787796402715d1b9cd59f80b628f/index.js#L266 + // @ts-expect-error undocumented, used to add assets to the output + compilation.emitAsset('metrics.json', metricsSource); + }); + } +} diff --git a/packages/kbn-optimizer/src/worker/emit_stats_plugin.ts b/packages/kbn-optimizer/src/worker/emit_stats_plugin.ts new file mode 100644 index 00000000000000..c964219e1fed61 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/emit_stats_plugin.ts @@ -0,0 +1,34 @@ +/* + * 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 Fs from 'fs'; +import Path from 'path'; + +import webpack from 'webpack'; + +import { Bundle } from '../common'; + +export class EmitStatsPlugin { + constructor(private readonly bundle: Bundle) {} + + public apply(compiler: webpack.Compiler) { + compiler.hooks.done.tap( + { + name: 'EmitStatsPlugin', + // run at the very end, ensure that it's after clean-webpack-plugin + stage: 10, + }, + (stats) => { + Fs.writeFileSync( + Path.resolve(this.bundle.outputDir, 'stats.json'), + JSON.stringify(stats.toJson()) + ); + } + ); + } +} diff --git a/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts new file mode 100644 index 00000000000000..6d296b9be089c0 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts @@ -0,0 +1,132 @@ +/* + * 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 webpack from 'webpack'; + +import Path from 'path'; +import { inspect } from 'util'; + +import { Bundle, WorkerConfig, ascending, parseFilePath } from '../common'; +import { BundleRefModule } from './bundle_ref_module'; +import { + isExternalModule, + isNormalModule, + isIgnoredModule, + isConcatenatedModule, + getModulePath, +} from './webpack_helpers'; + +/** + * sass-loader creates about a 40% overhead on the overall optimizer runtime, and + * so this constant is used to indicate to assignBundlesToWorkers() that there is + * extra work done in a bundle that has a lot of scss imports. The value is + * arbitrary and just intended to weigh the bundles so that they are distributed + * across mulitple workers on machines with lots of cores. + */ +const EXTRA_SCSS_WORK_UNITS = 100; + +export class PopulateBundleCachePlugin { + constructor(private readonly workerConfig: WorkerConfig, private readonly bundle: Bundle) {} + + public apply(compiler: webpack.Compiler) { + const { bundle, workerConfig } = this; + + compiler.hooks.emit.tap( + { + name: 'PopulateBundleCachePlugin', + before: ['BundleMetricsPlugin'], + }, + (compilation) => { + const bundleRefExportIds: string[] = []; + const referencedFiles = new Set(); + let moduleCount = 0; + let workUnits = compilation.fileDependencies.size; + + if (bundle.manifestPath) { + referencedFiles.add(bundle.manifestPath); + } + + for (const module of compilation.modules) { + if (isNormalModule(module)) { + moduleCount += 1; + const path = getModulePath(module); + const parsedPath = parseFilePath(path); + + if (!parsedPath.dirs.includes('node_modules')) { + referencedFiles.add(path); + + if (path.endsWith('.scss')) { + workUnits += EXTRA_SCSS_WORK_UNITS; + + for (const depPath of module.buildInfo.fileDependencies) { + referencedFiles.add(depPath); + } + } + + continue; + } + + const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); + const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); + referencedFiles.add( + Path.join( + parsedPath.root, + ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), + 'package.json' + ) + ); + continue; + } + + if (module instanceof BundleRefModule) { + bundleRefExportIds.push(module.ref.exportId); + continue; + } + + if (isConcatenatedModule(module)) { + moduleCount += module.modules.length; + continue; + } + + if (isExternalModule(module) || isIgnoredModule(module)) { + continue; + } + + throw new Error(`Unexpected module type: ${inspect(module)}`); + } + + const files = Array.from(referencedFiles).sort(ascending((p) => p)); + const mtimes = new Map( + files.map((path): [string, number | undefined] => { + try { + return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs]; + } catch (error) { + if (error?.code === 'ENOENT') { + return [path, undefined]; + } + + throw error; + } + }) + ); + + bundle.cache.set({ + bundleRefExportIds: bundleRefExportIds.sort(ascending((p) => p)), + optimizerCacheKey: workerConfig.optimizerCacheKey, + cacheKey: bundle.createCacheKey(files, mtimes), + moduleCount, + workUnits, + files, + }); + + // write the cache to the compilation so that it isn't cleaned by clean-webpack-plugin + bundle.cache.writeWebpackAsset(compilation); + } + ); + } +} diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 61f9c243a4def2..4f5bb23c3550d2 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -8,46 +8,16 @@ import 'source-map-support/register'; -import Fs from 'fs'; -import Path from 'path'; -import { inspect } from 'util'; - import webpack, { Stats } from 'webpack'; import * as Rx from 'rxjs'; import { mergeMap, map, mapTo, takeUntil } from 'rxjs/operators'; -import { - CompilerMsgs, - CompilerMsg, - maybeMap, - Bundle, - WorkerConfig, - ascending, - parseFilePath, - BundleRefs, -} from '../common'; -import { BundleRefModule } from './bundle_ref_module'; +import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig, BundleRefs } from '../common'; import { getWebpackConfig } from './webpack.config'; import { isFailureStats, failedStatsToErrorMessage } from './webpack_helpers'; -import { - isExternalModule, - isNormalModule, - isIgnoredModule, - isConcatenatedModule, - getModulePath, -} from './webpack_helpers'; const PLUGIN_NAME = '@kbn/optimizer'; -/** - * sass-loader creates about a 40% overhead on the overall optimizer runtime, and - * so this constant is used to indicate to assignBundlesToWorkers() that there is - * extra work done in a bundle that has a lot of scss imports. The value is - * arbitrary and just intended to weigh the bundles so that they are distributed - * across mulitple workers on machines with lots of cores. - */ -const EXTRA_SCSS_WORK_UNITS = 100; - /** * Create an Observable for a specific child compiler + bundle */ @@ -80,13 +50,6 @@ const observeCompiler = ( return undefined; } - if (workerConfig.profileWebpack) { - Fs.writeFileSync( - Path.resolve(bundle.outputDir, 'stats.json'), - JSON.stringify(stats.toJson()) - ); - } - if (!workerConfig.watch) { process.nextTick(() => done$.next()); } @@ -97,88 +60,11 @@ const observeCompiler = ( }); } - const bundleRefExportIds: string[] = []; - const referencedFiles = new Set(); - let moduleCount = 0; - let workUnits = stats.compilation.fileDependencies.size; - - if (bundle.manifestPath) { - referencedFiles.add(bundle.manifestPath); - } - - for (const module of stats.compilation.modules) { - if (isNormalModule(module)) { - moduleCount += 1; - const path = getModulePath(module); - const parsedPath = parseFilePath(path); - - if (!parsedPath.dirs.includes('node_modules')) { - referencedFiles.add(path); - - if (path.endsWith('.scss')) { - workUnits += EXTRA_SCSS_WORK_UNITS; - - for (const depPath of module.buildInfo.fileDependencies) { - referencedFiles.add(depPath); - } - } - - continue; - } - - const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); - const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); - referencedFiles.add( - Path.join( - parsedPath.root, - ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), - 'package.json' - ) - ); - continue; - } - - if (module instanceof BundleRefModule) { - bundleRefExportIds.push(module.ref.exportId); - continue; - } - - if (isConcatenatedModule(module)) { - moduleCount += module.modules.length; - continue; - } - - if (isExternalModule(module) || isIgnoredModule(module)) { - continue; - } - - throw new Error(`Unexpected module type: ${inspect(module)}`); + const moduleCount = bundle.cache.getModuleCount(); + if (moduleCount === undefined) { + throw new Error(`moduleCount wasn't populated by PopulateBundleCachePlugin`); } - const files = Array.from(referencedFiles).sort(ascending((p) => p)); - const mtimes = new Map( - files.map((path): [string, number | undefined] => { - try { - return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs]; - } catch (error) { - if (error?.code === 'ENOENT') { - return [path, undefined]; - } - - throw error; - } - }) - ); - - bundle.cache.set({ - bundleRefExportIds: bundleRefExportIds.sort(ascending((p) => p)), - optimizerCacheKey: workerConfig.optimizerCacheKey, - cacheKey: bundle.createCacheKey(files, mtimes), - moduleCount, - workUnits, - files, - }); - return compilerMsgs.compilerSuccess({ moduleCount, }); diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 331fbde6ea0bac..c4beb959284cc7 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -19,6 +19,9 @@ import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { Bundle, BundleRefs, WorkerConfig } from '../common'; import { BundleRefsPlugin } from './bundle_refs_plugin'; +import { BundleMetricsPlugin } from './bundle_metrics_plugin'; +import { EmitStatsPlugin } from './emit_stats_plugin'; +import { PopulateBundleCachePlugin } from './populate_bundle_cache_plugin'; const IS_CODE_COVERAGE = !!process.env.CODE_COVERAGE; const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'); @@ -67,6 +70,9 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: plugins: [ new CleanWebpackPlugin(), new BundleRefsPlugin(bundle, bundleRefs), + new PopulateBundleCachePlugin(worker, bundle), + new BundleMetricsPlugin(bundle), + ...(worker.profileWebpack ? [new EmitStatsPlugin(bundle)] : []), ...(bundle.banner ? [new webpack.BannerPlugin({ banner: bundle.banner, raw: true })] : []), ], diff --git a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts index 559d9da35c3206..9723c0107cf8e4 100644 --- a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts +++ b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts @@ -74,13 +74,14 @@ it('builds a generated plugin into a viable archive', async () => { await extract(PLUGIN_ARCHIVE, { dir: TMP_DIR }); - const files = await globby(['**/*'], { cwd: TMP_DIR }); + const files = await globby(['**/*'], { cwd: TMP_DIR, dot: true }); files.sort((a, b) => a.localeCompare(b)); expect(files).toMatchInlineSnapshot(` Array [ "kibana/fooTestPlugin/common/index.js", "kibana/fooTestPlugin/kibana.json", + "kibana/fooTestPlugin/node_modules/.yarn-integrity", "kibana/fooTestPlugin/package.json", "kibana/fooTestPlugin/server/index.js", "kibana/fooTestPlugin/server/plugin.js", diff --git a/packages/kbn-plugin-helpers/src/tasks/optimize.ts b/packages/kbn-plugin-helpers/src/tasks/optimize.ts index 0f0ac93086c9e7..2478947e79f188 100644 --- a/packages/kbn-plugin-helpers/src/tasks/optimize.ts +++ b/packages/kbn-plugin-helpers/src/tasks/optimize.ts @@ -34,9 +34,15 @@ export async function optimize({ log, plugin, sourceDir, buildDir }: BuildContex pluginScanDirs: [], }); + const target = Path.resolve(sourceDir, 'target'); + await runOptimizer(config).pipe(logOptimizerState(log, config)).toPromise(); + // clean up unnecessary files + Fs.unlinkSync(Path.resolve(target, 'public/metrics.json')); + Fs.unlinkSync(Path.resolve(target, 'public/.kbn-optimizer-cache')); + // move target into buildDir - await asyncRename(Path.resolve(sourceDir, 'target'), Path.resolve(buildDir, 'target')); + await asyncRename(target, Path.resolve(buildDir, 'target')); log.indent(-2); } diff --git a/scripts/ship_ci_stats.js b/scripts/ship_ci_stats.js new file mode 100644 index 00000000000000..5aed9fc446240d --- /dev/null +++ b/scripts/ship_ci_stats.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +require('../src/setup_node_env/no_transpilation'); +require('@kbn/dev-utils').shipCiStatsCli(); diff --git a/src/dev/build/tasks/build_kibana_platform_plugins.ts b/src/dev/build/tasks/build_kibana_platform_plugins.ts index 91fad2ca52617f..d2d2d3275270b8 100644 --- a/src/dev/build/tasks/build_kibana_platform_plugins.ts +++ b/src/dev/build/tasks/build_kibana_platform_plugins.ts @@ -6,20 +6,18 @@ * Side Public License, v 1. */ +import Path from 'path'; + import { REPO_ROOT } from '@kbn/utils'; -import { CiStatsReporter } from '@kbn/dev-utils'; -import { - runOptimizer, - OptimizerConfig, - logOptimizerState, - reportOptimizerStats, -} from '@kbn/optimizer'; +import { lastValueFrom } from '@kbn/std'; +import { CiStatsMetrics } from '@kbn/dev-utils'; +import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer'; -import { Task } from '../lib'; +import { Task, deleteAll, write, read } from '../lib'; export const BuildKibanaPlatformPlugins: Task = { description: 'Building distributable versions of Kibana platform plugins', - async run(_, log, build) { + async run(buildConfig, log, build) { const config = OptimizerConfig.create({ repoRoot: REPO_ROOT, outputRoot: build.resolvePath(), @@ -31,12 +29,27 @@ export const BuildKibanaPlatformPlugins: Task = { includeCoreBundle: true, }); - const reporter = CiStatsReporter.fromEnv(log); + await lastValueFrom(runOptimizer(config).pipe(logOptimizerState(log, config))); + + const combinedMetrics: CiStatsMetrics = []; + const metricFilePaths: string[] = []; + for (const bundle of config.bundles) { + const path = Path.resolve(bundle.outputDir, 'metrics.json'); + const metrics: CiStatsMetrics = JSON.parse(await read(path)); + combinedMetrics.push(...metrics); + metricFilePaths.push(path); + } + + // write combined metrics to target + await write( + buildConfig.resolveFromTarget('optimizer_bundle_metrics.json'), + JSON.stringify(combinedMetrics, null, 2) + ); - await runOptimizer(config) - .pipe(reportOptimizerStats(reporter, config, log), logOptimizerState(log, config)) - .toPromise(); + // delete all metric files + await deleteAll(metricFilePaths, log); + // delete all bundle cache files await Promise.all(config.bundles.map((b) => b.cache.clear())); }, }; diff --git a/test/scripts/jenkins_baseline.sh b/test/scripts/jenkins_baseline.sh index e679ac7f31bd15..60926238576c77 100755 --- a/test/scripts/jenkins_baseline.sh +++ b/test/scripts/jenkins_baseline.sh @@ -5,6 +5,10 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" echo " -> building and extracting OSS Kibana distributable for use in functional tests" node scripts/build --debug --oss + +echo " -> shipping metrics from build to ci-stats" +node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 6184708ea3fc62..5819a3ce6765e1 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -17,6 +17,9 @@ if [[ -z "$CODE_COVERAGE" ]] ; then echo " -> building and extracting OSS Kibana distributable for use in functional tests" node scripts/build --debug --oss + echo " -> shipping metrics from build to ci-stats" + node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + mkdir -p "$WORKSPACE/kibana-build-oss" cp -pR build/oss/kibana-*-SNAPSHOT-linux-x86_64/. $WORKSPACE/kibana-build-oss/ fi diff --git a/test/scripts/jenkins_xpack_baseline.sh b/test/scripts/jenkins_xpack_baseline.sh index 7577b6927d166f..aaacdd4ea3aaec 100755 --- a/test/scripts/jenkins_xpack_baseline.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -6,6 +6,10 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh" echo " -> building and extracting default Kibana distributable" cd "$KIBANA_DIR" node scripts/build --debug --no-oss + +echo " -> shipping metrics from build to ci-stats" +node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" mkdir -p "$installDir" diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index a9e603f63bd42b..36865ce7c4967a 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -32,6 +32,10 @@ if [[ -z "$CODE_COVERAGE" ]] ; then echo " -> building and extracting default Kibana distributable for use in functional tests" cd "$KIBANA_DIR" node scripts/build --debug --no-oss + + echo " -> shipping metrics from build to ci-stats" + node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" mkdir -p "$installDir" diff --git a/yarn.lock b/yarn.lock index 6df258e9715b78..ec6cf338a43da2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6803,7 +6803,7 @@ dependencies: "@types/webpack" "*" -"@types/webpack-sources@*": +"@types/webpack-sources@*", "@types/webpack-sources@^0.1.4": version "0.1.5" resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-0.1.5.tgz#be47c10f783d3d6efe1471ff7f042611bd464a92" integrity sha512-zfvjpp7jiafSmrzJ2/i3LqOyTYTuJ7u1KOXlKgDlvsj9Rr0x7ZiYu5lZbXwobL7lmsRNtPXlBfmaUD8eU2Hu8w== From 9684661da4e674f77a00bd59b9e5aa3897d418eb Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Mon, 8 Feb 2021 13:28:18 -0600 Subject: [PATCH 10/18] [Metrics UI] Add ability to filter anomaly detection datafeed (#89721) * Add null check for empty process data * Add Ability to filter datafeed for ml jobs * Merge user-defined query with default query Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/containers/ml/infra_ml_module.tsx | 9 ++- .../containers/ml/infra_ml_module_types.ts | 3 +- .../metrics_hosts/module_descriptor.ts | 18 ++++- .../modules/metrics_k8s/module_descriptor.ts | 18 ++++- .../ml/anomaly_detection/job_setup_screen.tsx | 65 +++++++++++++++++-- 5 files changed, 98 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/infra/public/containers/ml/infra_ml_module.tsx b/x-pack/plugins/infra/public/containers/ml/infra_ml_module.tsx index a94f2dd57c482f..b55ae65e58e91c 100644 --- a/x-pack/plugins/infra/public/containers/ml/infra_ml_module.tsx +++ b/x-pack/plugins/infra/public/containers/ml/infra_ml_module.tsx @@ -6,7 +6,6 @@ */ import { useCallback, useMemo } from 'react'; -import { DatasetFilter } from '../../../common/infra_ml'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import { useTrackedPromise } from '../../utils/use_tracked_promise'; import { useModuleStatus } from './infra_ml_module_status'; @@ -52,7 +51,7 @@ export const useInfraMLModule = ({ selectedIndices: string[], start: number | undefined, end: number | undefined, - datasetFilter: DatasetFilter, + filter: string, partitionField?: string ) => { dispatchModuleStatus({ type: 'startedSetup' }); @@ -60,7 +59,7 @@ export const useInfraMLModule = ({ { start, end, - datasetFilter, + filter, moduleSourceConfiguration: { indices: selectedIndices, sourceId, @@ -114,13 +113,13 @@ export const useInfraMLModule = ({ selectedIndices: string[], start: number | undefined, end: number | undefined, - datasetFilter: DatasetFilter, + filter: string, partitionField?: string ) => { dispatchModuleStatus({ type: 'startedSetup' }); cleanUpModule() .then(() => { - setUpModule(selectedIndices, start, end, datasetFilter, partitionField); + setUpModule(selectedIndices, start, end, filter, partitionField); }) .catch(() => { dispatchModuleStatus({ type: 'failedSetup' }); diff --git a/x-pack/plugins/infra/public/containers/ml/infra_ml_module_types.ts b/x-pack/plugins/infra/public/containers/ml/infra_ml_module_types.ts index e681290570b8cc..5a5272f7830530 100644 --- a/x-pack/plugins/infra/public/containers/ml/infra_ml_module_types.ts +++ b/x-pack/plugins/infra/public/containers/ml/infra_ml_module_types.ts @@ -10,7 +10,6 @@ import { ValidateLogEntryDatasetsResponsePayload, ValidationIndicesResponsePayload, } from '../../../common/http_api/log_analysis'; -import { DatasetFilter } from '../../../common/infra_ml'; import { DeleteJobsResponsePayload } from './api/ml_cleanup'; import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; import { GetMlModuleResponsePayload } from './api/ml_get_module'; @@ -21,7 +20,7 @@ export { JobModelSizeStats, JobSummary } from './api/ml_get_jobs_summary_api'; export interface SetUpModuleArgs { start?: number | undefined; end?: number | undefined; - datasetFilter?: DatasetFilter; + filter?: any; moduleSourceConfiguration: ModuleSourceConfiguration; partitionField?: string; } diff --git a/x-pack/plugins/infra/public/containers/ml/modules/metrics_hosts/module_descriptor.ts b/x-pack/plugins/infra/public/containers/ml/modules/metrics_hosts/module_descriptor.ts index b8d09fdb5e3250..a7ab948d052aab 100644 --- a/x-pack/plugins/infra/public/containers/ml/modules/metrics_hosts/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/ml/modules/metrics_hosts/module_descriptor.ts @@ -67,6 +67,7 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) const { start, end, + filter, moduleSourceConfiguration: { spaceId, sourceId, indices, timestampField }, partitionField, } = setUpModuleArgs; @@ -107,10 +108,23 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) const datafeedOverrides = jobIds.map((id) => { const { datafeed: defaultDatafeedConfig } = getDefaultJobConfigs(id); + const config = { ...defaultDatafeedConfig }; + + if (filter) { + const query = JSON.parse(filter); + + config.query.bool = { + ...config.query.bool, + ...query.bool, + }; + } if (!partitionField || id === 'hosts_memory_usage') { // Since the host memory usage doesn't have custom aggs, we don't need to do anything to add a partition field - return defaultDatafeedConfig; + return { + ...config, + job_id: id, + }; } // If we have a partition field, we need to change the aggregation to do a terms agg at the top level @@ -126,7 +140,7 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) }; return { - ...defaultDatafeedConfig, + ...config, job_id: id, aggregations, }; diff --git a/x-pack/plugins/infra/public/containers/ml/modules/metrics_k8s/module_descriptor.ts b/x-pack/plugins/infra/public/containers/ml/modules/metrics_k8s/module_descriptor.ts index fe92b290dfde3c..4c5eb5fd4bf239 100644 --- a/x-pack/plugins/infra/public/containers/ml/modules/metrics_k8s/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/ml/modules/metrics_k8s/module_descriptor.ts @@ -68,6 +68,7 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) const { start, end, + filter, moduleSourceConfiguration: { spaceId, sourceId, indices, timestampField }, partitionField, } = setUpModuleArgs; @@ -107,10 +108,23 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) const datafeedOverrides = jobIds.map((id) => { const { datafeed: defaultDatafeedConfig } = getDefaultJobConfigs(id); + const config = { ...defaultDatafeedConfig }; + + if (filter) { + const query = JSON.parse(filter); + + config.query.bool = { + ...config.query.bool, + ...query.bool, + }; + } if (!partitionField || id === 'k8s_memory_usage') { // Since the host memory usage doesn't have custom aggs, we don't need to do anything to add a partition field - return defaultDatafeedConfig; + return { + ...config, + job_id: id, + }; } // Because the ML K8s jobs ship with a default partition field of {kubernetes.namespace}, ignore that agg and wrap it in our own agg. @@ -131,7 +145,7 @@ const setUpModule = async (setUpModuleArgs: SetUpModuleArgs, fetch: HttpHandler) }; return { - ...defaultDatafeedConfig, + ...config, job_id: id, aggregations, }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx index 3236cbc59a07b1..894f76318bcfe3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/job_setup_screen.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { debounce } from 'lodash'; import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiForm, EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; import { EuiText, EuiSpacer } from '@elastic/eui'; @@ -22,6 +22,8 @@ import { useMetricK8sModuleContext } from '../../../../../../containers/ml/modul import { useMetricHostsModuleContext } from '../../../../../../containers/ml/modules/metrics_hosts/module'; import { FixedDatePicker } from '../../../../../../components/fixed_datepicker'; import { DEFAULT_K8S_PARTITION_FIELD } from '../../../../../../containers/ml/modules/metrics_k8s/module_descriptor'; +import { MetricsExplorerKueryBar } from '../../../../metrics_explorer/components/kuery_bar'; +import { convertKueryToElasticSearchQuery } from '../../../../../../utils/kuery'; interface Props { jobType: 'hosts' | 'kubernetes'; @@ -36,6 +38,8 @@ export const JobSetupScreen = (props: Props) => { const [partitionField, setPartitionField] = useState(null); const h = useMetricHostsModuleContext(); const k = useMetricK8sModuleContext(); + const [filter, setFilter] = useState(''); + const [filterQuery, setFilterQuery] = useState(''); const { createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -89,7 +93,7 @@ export const JobSetupScreen = (props: Props) => { indicies, moment(startDate).toDate().getTime(), undefined, - { type: 'includeAll' }, + filterQuery, partitionField ? partitionField[0] : undefined ); } else { @@ -97,11 +101,30 @@ export const JobSetupScreen = (props: Props) => { indicies, moment(startDate).toDate().getTime(), undefined, - { type: 'includeAll' }, + filterQuery, partitionField ? partitionField[0] : undefined ); } - }, [cleanUpAndSetUpModule, setUpModule, hasSummaries, indicies, partitionField, startDate]); + }, [ + cleanUpAndSetUpModule, + filterQuery, + setUpModule, + hasSummaries, + indicies, + partitionField, + startDate, + ]); + + const onFilterChange = useCallback( + (f: string) => { + setFilter(f || ''); + setFilterQuery(convertKueryToElasticSearchQuery(f, derivedIndexPattern) || ''); + }, + [derivedIndexPattern] + ); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const debouncedOnFilterChange = useCallback(debounce(onFilterChange, 500), [onFilterChange]); const onPartitionFieldChange = useCallback((value: Array<{ label: string }>) => { setPartitionField(value.map((v) => v.label)); @@ -250,6 +273,40 @@ export const JobSetupScreen = (props: Props) => { />
+ + + + + } + description={ + + } + > + + } + > + + + )} From 3722bea42f03fc7d2799d88fed6bb1aaba945055 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 8 Feb 2021 12:53:54 -0700 Subject: [PATCH 11/18] [Maps] clamp MVT too many features polygon to tile boundary (#90444) * [Maps] clamp MVT too many features polygon to tile boundary * add mapbox_styles to index.js Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/maps/server/mvt/get_tile.ts | 84 ++-- x-pack/test/functional/apps/maps/index.js | 1 + x-pack/test/functional/apps/maps/joins.js | 34 -- .../functional/apps/maps/mapbox_styles.js | 358 +++++++++++------- 4 files changed, 242 insertions(+), 235 deletions(-) diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 3116838d26fb5a..50c2014275a0f2 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -25,7 +25,7 @@ import { import { convertRegularRespToGeoJson, hitsToGeoJson } from '../../common/elasticsearch_util'; import { flattenHit } from './util'; -import { ESBounds, tile2lat, tile2long, tileToESBbox } from '../../common/geo_tile_utils'; +import { ESBounds, tileToESBbox } from '../../common/geo_tile_utils'; import { getCentroidFeatures } from '../../common/get_centroid_features'; export async function getGridTile({ @@ -53,35 +53,14 @@ export async function getGridTile({ geoFieldType: ES_GEO_FIELD_TYPE; searchSessionId?: string; }): Promise { - const esBbox: ESBounds = tileToESBbox(x, y, z); try { - let bboxFilter; - if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { - bboxFilter = { - geo_bounding_box: { - [geometryFieldName]: esBbox, - }, - }; - } else if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_SHAPE) { - const geojsonPolygon = tileToGeoJsonPolygon(x, y, z); - bboxFilter = { - geo_shape: { - [geometryFieldName]: { - shape: geojsonPolygon, - relation: 'INTERSECTS', - }, - }, - }; - } else { - throw new Error(`${geoFieldType} is not valid geo field-type`); - } - requestBody.query.bool.filter.push(bboxFilter); - + const tileBounds: ESBounds = tileToESBbox(x, y, z); + requestBody.query.bool.filter.push(getTileSpatialFilter(geometryFieldName, tileBounds)); requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.precision = Math.min( z + SUPER_FINE_ZOOM_DELTA, MAX_ZOOM ); - requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.bounds = esBbox; + requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.bounds = tileBounds; const response = await context .search!.search( @@ -134,14 +113,9 @@ export async function getTile({ }): Promise { let features: Feature[]; try { - requestBody.query.bool.filter.push({ - geo_shape: { - [geometryFieldName]: { - shape: tileToGeoJsonPolygon(x, y, z), - relation: 'INTERSECTS', - }, - }, - }); + requestBody.query.bool.filter.push( + getTileSpatialFilter(geometryFieldName, tileToESBbox(x, y, z)) + ); const searchOptions = { sessionId: searchSessionId, @@ -193,7 +167,8 @@ export async function getTile({ [KBN_TOO_MANY_FEATURES_PROPERTY]: true, }, geometry: esBboxToGeoJsonPolygon( - bboxResponse.rawResponse.aggregations.data_bounds.bounds + bboxResponse.rawResponse.aggregations.data_bounds.bounds, + tileToESBbox(x, y, z) ), }, ]; @@ -244,32 +219,31 @@ export async function getTile({ } } -function tileToGeoJsonPolygon(x: number, y: number, z: number): Polygon { - const wLon = tile2long(x, z); - const sLat = tile2lat(y + 1, z); - const eLon = tile2long(x + 1, z); - const nLat = tile2lat(y, z); - +function getTileSpatialFilter(geometryFieldName: string, tileBounds: ESBounds): unknown { return { - type: 'Polygon', - coordinates: [ - [ - [wLon, sLat], - [wLon, nLat], - [eLon, nLat], - [eLon, sLat], - [wLon, sLat], - ], - ], + geo_shape: { + [geometryFieldName]: { + shape: { + type: 'envelope', + // upper left and lower right points of the shape to represent a bounding rectangle in the format [[minLon, maxLat], [maxLon, minLat]] + coordinates: [ + [tileBounds.top_left.lon, tileBounds.top_left.lat], + [tileBounds.bottom_right.lon, tileBounds.bottom_right.lat], + ], + }, + relation: 'INTERSECTS', + }, + }, }; } -function esBboxToGeoJsonPolygon(esBounds: ESBounds): Polygon { - let minLon = esBounds.top_left.lon; - const maxLon = esBounds.bottom_right.lon; +function esBboxToGeoJsonPolygon(esBounds: ESBounds, tileBounds: ESBounds): Polygon { + // Intersecting geo_shapes may push bounding box outside of tile so need to clamp to tile bounds. + let minLon = Math.max(esBounds.top_left.lon, tileBounds.top_left.lon); + const maxLon = Math.min(esBounds.bottom_right.lon, tileBounds.bottom_right.lon); minLon = minLon > maxLon ? minLon - 360 : minLon; // fixes an ES bbox to straddle dateline - const minLat = esBounds.bottom_right.lat; - const maxLat = esBounds.top_left.lat; + const minLat = Math.max(esBounds.bottom_right.lat, tileBounds.bottom_right.lat); + const maxLat = Math.min(esBounds.top_left.lat, tileBounds.top_left.lat); return { type: 'Polygon', diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index d76afb7ebdc249..dd20ed58afbc67 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -47,6 +47,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./es_geo_grid_source')); loadTestFile(require.resolve('./es_pew_pew_source')); loadTestFile(require.resolve('./joins')); + loadTestFile(require.resolve('./mapbox_styles')); loadTestFile(require.resolve('./mvt_scaling')); loadTestFile(require.resolve('./mvt_super_fine')); loadTestFile(require.resolve('./add_layer_panel')); diff --git a/x-pack/test/functional/apps/maps/joins.js b/x-pack/test/functional/apps/maps/joins.js index 094f5335cd05ff..49717016f9c607 100644 --- a/x-pack/test/functional/apps/maps/joins.js +++ b/x-pack/test/functional/apps/maps/joins.js @@ -7,8 +7,6 @@ import expect from '@kbn/expect'; -import { MAPBOX_STYLES } from './mapbox_styles'; - const JOIN_PROPERTY_NAME = '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1'; const EXPECTED_JOIN_VALUES = { alpha: 10, @@ -18,10 +16,6 @@ const EXPECTED_JOIN_VALUES = { }; const VECTOR_SOURCE_ID = 'n1t6f'; -const CIRCLE_STYLE_LAYER_INDEX = 0; -const FILL_STYLE_LAYER_INDEX = 2; -const LINE_STYLE_LAYER_INDEX = 3; -const TOO_MANY_FEATURES_LAYER_INDEX = 4; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); @@ -95,34 +89,6 @@ export default function ({ getPageObjects, getService }) { }); }); - it('should style fills, points, lines, and bounding-boxes independently', async () => { - const mapboxStyle = await PageObjects.maps.getMapboxStyle(); - const layersForVectorSource = mapboxStyle.layers.filter((mbLayer) => { - return mbLayer.id.startsWith(VECTOR_SOURCE_ID); - }); - - //circle layer for points - expect(layersForVectorSource[CIRCLE_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.POINT_LAYER); - - //fill layer - expect(layersForVectorSource[FILL_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.FILL_LAYER); - - //line layer for borders - expect(layersForVectorSource[LINE_STYLE_LAYER_INDEX]).to.eql(MAPBOX_STYLES.LINE_LAYER); - - //Too many features layer (this is a static style config) - expect(layersForVectorSource[TOO_MANY_FEATURES_LAYER_INDEX]).to.eql({ - id: 'n1t6f_toomanyfeatures', - type: 'fill', - source: 'n1t6f', - minzoom: 0, - maxzoom: 24, - filter: ['==', ['get', '__kbn_too_many_features__'], true], - layout: { visibility: 'visible' }, - paint: { 'fill-pattern': '__kbn_too_many_features_image_id__', 'fill-opacity': 0.75 }, - }); - }); - it('should flag only the joined features as visible', async () => { const mapboxStyle = await PageObjects.maps.getMapboxStyle(); const vectorSource = mapboxStyle.sources[VECTOR_SOURCE_ID]; diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index d4496f13b8bef4..b483b95e0ca1fc 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -5,176 +5,242 @@ * 2.0. */ -export const MAPBOX_STYLES = { - POINT_LAYER: { - id: 'n1t6f_circle', - type: 'circle', - source: 'n1t6f', - minzoom: 0, - maxzoom: 24, - filter: [ - 'all', - ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], - ], - ], - layout: { visibility: 'visible' }, - paint: { - 'circle-color': [ - 'interpolate', - ['linear'], - [ - 'coalesce', +import expect from '@kbn/expect'; + +export default function ({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + const security = getService('security'); + + describe('mapbox styles', () => { + let mapboxStyle; + before(async () => { + await security.testUser.setRoles( + ['global_maps_all', 'geoshape_data_reader', 'meta_for_geoshape_data_reader'], + false + ); + await PageObjects.maps.loadSavedMap('join example'); + mapboxStyle = await PageObjects.maps.getMapboxStyle(); + }); + + after(async () => { + await inspector.close(); + await security.testUser.restoreDefaults(); + }); + + it('should style circle layer as expected', async () => { + const layer = mapboxStyle.layers.find((mbLayer) => { + return mbLayer.id === 'n1t6f_circle'; + }); + expect(layer).to.eql({ + id: 'n1t6f_circle', + type: 'circle', + source: 'n1t6f', + minzoom: 0, + maxzoom: 24, + filter: [ + 'all', + ['==', ['get', '__kbn_isvisibleduetojoin__'], true], [ - 'case', - [ - '==', - ['feature-state', '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1'], - null, - ], - 2, + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], + ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], + ], + ], + layout: { visibility: 'visible' }, + paint: { + 'circle-color': [ + 'interpolate', + ['linear'], [ - 'max', + 'coalesce', [ - 'min', + 'case', [ - 'to-number', + '==', [ 'feature-state', '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1', ], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1', + ], + ], + 12, + ], + 3, ], - 12, ], - 3, + 2, ], + 2, + 'rgba(0,0,0,0)', + 3, + '#ecf1f7', + 4.125, + '#d9e3ef', + 5.25, + '#c5d5e7', + 6.375, + '#b2c7df', + 7.5, + '#9eb9d8', + 8.625, + '#8bacd0', + 9.75, + '#769fc8', + 10.875, + '#6092c0', ], - 2, - ], - 2, - 'rgba(0,0,0,0)', - 3, - '#ecf1f7', - 4.125, - '#d9e3ef', - 5.25, - '#c5d5e7', - 6.375, - '#b2c7df', - 7.5, - '#9eb9d8', - 8.625, - '#8bacd0', - 9.75, - '#769fc8', - 10.875, - '#6092c0', - ], - 'circle-opacity': 0.75, - 'circle-stroke-color': '#41937c', - 'circle-stroke-opacity': 0.75, - 'circle-stroke-width': 1, - 'circle-radius': 10, - }, - }, - FILL_LAYER: { - id: 'n1t6f_fill', - type: 'fill', - source: 'n1t6f', - minzoom: 0, - maxzoom: 24, - filter: [ - 'all', - ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], - ], - ], - layout: { visibility: 'visible' }, - paint: { - 'fill-color': [ - 'interpolate', - ['linear'], - [ - 'coalesce', + 'circle-opacity': 0.75, + 'circle-stroke-color': '#41937c', + 'circle-stroke-opacity': 0.75, + 'circle-stroke-width': 1, + 'circle-radius': 10, + }, + }); + }); + + it('should style fill layer as expected', async () => { + const layer = mapboxStyle.layers.find((mbLayer) => { + return mbLayer.id === 'n1t6f_fill'; + }); + expect(layer).to.eql({ + id: 'n1t6f_fill', + type: 'fill', + source: 'n1t6f', + minzoom: 0, + maxzoom: 24, + filter: [ + 'all', + ['==', ['get', '__kbn_isvisibleduetojoin__'], true], [ - 'case', + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], [ - '==', - ['feature-state', '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1'], - null, + 'any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'], ], - 2, + ], + ], + layout: { visibility: 'visible' }, + paint: { + 'fill-color': [ + 'interpolate', + ['linear'], [ - 'max', + 'coalesce', [ - 'min', + 'case', [ - 'to-number', + '==', [ 'feature-state', '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1', ], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1', + ], + ], + 12, + ], + 3, ], - 12, ], - 3, + 2, + ], + 2, + 'rgba(0,0,0,0)', + 3, + '#ecf1f7', + 4.125, + '#d9e3ef', + 5.25, + '#c5d5e7', + 6.375, + '#b2c7df', + 7.5, + '#9eb9d8', + 8.625, + '#8bacd0', + 9.75, + '#769fc8', + 10.875, + '#6092c0', + ], + 'fill-opacity': 0.75, + }, + }); + }); + + it('should style fill layer as expected', async () => { + const layer = mapboxStyle.layers.find((mbLayer) => { + return mbLayer.id === 'n1t6f_line'; + }); + expect(layer).to.eql({ + id: 'n1t6f_line', + type: 'line', + source: 'n1t6f', + minzoom: 0, + maxzoom: 24, + filter: [ + 'all', + ['==', ['get', '__kbn_isvisibleduetojoin__'], true], + [ + 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], + [ + 'any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'], + ['==', ['geometry-type'], 'LineString'], + ['==', ['geometry-type'], 'MultiLineString'], ], ], - 2, - ], - 2, - 'rgba(0,0,0,0)', - 3, - '#ecf1f7', - 4.125, - '#d9e3ef', - 5.25, - '#c5d5e7', - 6.375, - '#b2c7df', - 7.5, - '#9eb9d8', - 8.625, - '#8bacd0', - 9.75, - '#769fc8', - 10.875, - '#6092c0', - ], - 'fill-opacity': 0.75, - }, - }, - LINE_LAYER: { - id: 'n1t6f_line', - type: 'line', - source: 'n1t6f', - minzoom: 0, - maxzoom: 24, - filter: [ - 'all', - ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - [ - 'any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'], - ['==', ['geometry-type'], 'LineString'], - ['==', ['geometry-type'], 'MultiLineString'], ], - ], - ], - layout: { visibility: 'visible' }, - paint: { 'line-color': '#41937c', 'line-opacity': 0.75, 'line-width': 1 }, - }, -}; + layout: { visibility: 'visible' }, + paint: { 'line-color': '#41937c', 'line-opacity': 0.75, 'line-width': 1 }, + }); + }); + + it('should style incomplete data layer as expected', async () => { + const layer = mapboxStyle.layers.find((mbLayer) => { + return mbLayer.id === 'n1t6f_toomanyfeatures'; + }); + expect(layer).to.eql({ + id: 'n1t6f_toomanyfeatures', + type: 'fill', + source: 'n1t6f', + minzoom: 0, + maxzoom: 24, + filter: ['==', ['get', '__kbn_too_many_features__'], true], + layout: { visibility: 'visible' }, + paint: { 'fill-pattern': '__kbn_too_many_features_image_id__', 'fill-opacity': 0.75 }, + }); + }); + }); +} From a1a2536b5bb624d9dce989389319d7d527377d79 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 8 Feb 2021 21:26:57 +0100 Subject: [PATCH 12/18] [Uptime] Waterfall filters (#89185) * WIP * Use multi canvas solution * type * fix test * adde unit tests * reduce item to 150 * update margins * use constant * update z-index * added key * wip * wip * wip filters * reorgnaise components * fix issue * update filter * only highlight button * water fall test * styling * fix styling * test * fix types * update test * update ari hidden * added click telemetry for waterfall filters * added input click telemetry * update filter behaviour * fixed typo * fix type * fix styling * persist original resource number in waterfall sidebar when showing only highlighted resources * update waterfall filter collapse checkbox content * update use_bar_charts to work with filtered data * update network request total label to include filtered requests * adjust telemetry * add accessible text * add waterfall chart view telemetry * updated mime type filter label translations * adjust total network requests to use FormattedMessage * adjust translations and tests * use FormattedMessage in NetworkRequestsTotal * ensure sidebar persists when 0 resources match filter * use destructuring in waterfall sidebar item * reset collapse requests checkbox when filters are removed * update license headers Co-authored-by: Dominique Clarke Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/observability/public/index.ts | 1 + .../waterfall/data_formatting.test.ts | 330 +++++++++++------- .../step_detail/waterfall/data_formatting.ts | 65 +++- .../synthetics/step_detail/waterfall/types.ts | 43 +-- .../waterfall_chart_wrapper.test.tsx | 248 +++++++++++++ .../waterfall/waterfall_chart_wrapper.tsx | 102 +++--- .../waterfall/waterfall_filter.test.tsx | 155 ++++++++ .../waterfall/waterfall_filter.tsx | 188 ++++++++++ .../waterfall/waterfall_sidebar_item.tsx | 56 +++ .../waterfalll_sidebar_item.test.tsx | 51 +++ .../waterfall/components/constants.ts | 2 + .../components/middle_truncated_text.test.tsx | 12 +- .../components/middle_truncated_text.tsx | 9 +- .../network_requests_total.test.tsx | 51 ++- .../components/network_requests_total.tsx | 45 ++- .../waterfall/components/sidebar.tsx | 17 +- .../synthetics/waterfall/components/styles.ts | 50 ++- .../waterfall/components/translations.ts | 50 +++ .../components/use_bar_charts.test.tsx | 46 ++- .../waterfall/components/use_bar_charts.ts | 31 +- .../waterfall/components/waterfall.test.tsx | 70 ++-- .../components/waterfall_bar_chart.tsx | 112 ++++++ .../waterfall/components/waterfall_chart.tsx | 221 ++++-------- .../components/waterfall_chart_fixed_axis.tsx | 65 ++++ .../waterfall/context/waterfall_chart.tsx | 11 +- .../uptime/public/hooks/use_chart_theme.ts | 20 ++ .../public/lib/helper/enzyme_helpers.tsx | 45 ++- .../uptime/public/lib/helper/rtl_helpers.tsx | 8 +- 28 files changed, 1632 insertions(+), 472 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/translations.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx create mode 100644 x-pack/plugins/uptime/public/hooks/use_chart_theme.ts diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index e9a9bb8146dbfe..1db5f62823e9bc 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -23,6 +23,7 @@ export { getCoreVitalsComponent, HeaderMenuPortal } from './components/shared/'; export { useTrackPageview, useUiTracker, + useTrackMetric, UiTracker, TrackMetricOptions, METRIC_TYPE, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index 487daf0332a985..a02116877f49a4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -5,10 +5,143 @@ * 2.0. */ -import { colourPalette, getSeriesAndDomain } from './data_formatting'; +import { colourPalette, getSeriesAndDomain, getSidebarItems } from './data_formatting'; import { NetworkItems, MimeType } from './types'; import { WaterfallDataEntry } from '../../waterfall/types'; +const networkItems: NetworkItems = [ + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'https://unpkg.com/todomvc-app-css@2.0.4/index.css', + status: 200, + mimeType: 'text/css', + requestSentTime: 18098833.175, + requestStartTime: 18098835.439, + loadEndTime: 18098957.145, + timings: { + connect: 81.10800000213203, + wait: 34.577999998873565, + receive: 0.5520000013348181, + send: 0.3600000018195715, + total: 123.97000000055414, + proxy: -1, + blocked: 0.8540000017092098, + queueing: 2.263999998831423, + ssl: 55.38700000033714, + dns: 3.559999997378327, + }, + }, + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'https://unpkg.com/director@1.2.8/build/director.js', + status: 200, + mimeType: 'application/javascript', + requestSentTime: 18098833.537, + requestStartTime: 18098837.233999997, + loadEndTime: 18098977.648000002, + timings: { + blocked: 84.54599999822676, + receive: 3.068000001803739, + queueing: 3.69700000010198, + proxy: -1, + total: 144.1110000014305, + wait: 52.56100000042352, + connect: -1, + send: 0.2390000008745119, + ssl: -1, + dns: -1, + }, + }, +]; + +const networkItemsWithoutFullTimings: NetworkItems = [ + networkItems[0], + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', + status: 0, + mimeType: 'text/javascript', + requestSentTime: 18098834.097, + loadEndTime: 18098836.889999997, + timings: { + total: 2.7929999996558763, + blocked: -1, + ssl: -1, + wait: -1, + connect: -1, + dns: -1, + queueing: -1, + send: -1, + proxy: -1, + receive: -1, + }, + }, +]; + +const networkItemsWithoutAnyTimings: NetworkItems = [ + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', + status: 0, + mimeType: 'text/javascript', + requestSentTime: 18098834.097, + loadEndTime: 18098836.889999997, + timings: { + total: -1, + blocked: -1, + ssl: -1, + wait: -1, + connect: -1, + dns: -1, + queueing: -1, + send: -1, + proxy: -1, + receive: -1, + }, + }, +]; + +const networkItemsWithoutTimingsObject: NetworkItems = [ + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', + status: 0, + mimeType: 'text/javascript', + requestSentTime: 18098834.097, + loadEndTime: 18098836.889999997, + }, +]; + +const networkItemsWithUncommonMimeType: NetworkItems = [ + { + timestamp: '2021-01-05T19:22:28.928Z', + method: 'GET', + url: 'https://unpkg.com/director@1.2.8/build/director.js', + status: 200, + mimeType: 'application/x-javascript', + requestSentTime: 18098833.537, + requestStartTime: 18098837.233999997, + loadEndTime: 18098977.648000002, + timings: { + blocked: 84.54599999822676, + receive: 3.068000001803739, + queueing: 3.69700000010198, + proxy: -1, + total: 144.1110000014305, + wait: 52.56100000042352, + connect: -1, + send: 0.2390000008745119, + ssl: -1, + dns: -1, + }, + }, +]; + describe('Palettes', () => { it('A colour palette comprising timing and mime type colours is correctly generated', () => { expect(colourPalette).toEqual({ @@ -30,139 +163,6 @@ describe('Palettes', () => { }); describe('getSeriesAndDomain', () => { - const networkItems: NetworkItems = [ - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'https://unpkg.com/todomvc-app-css@2.0.4/index.css', - status: 200, - mimeType: 'text/css', - requestSentTime: 18098833.175, - requestStartTime: 18098835.439, - loadEndTime: 18098957.145, - timings: { - connect: 81.10800000213203, - wait: 34.577999998873565, - receive: 0.5520000013348181, - send: 0.3600000018195715, - total: 123.97000000055414, - proxy: -1, - blocked: 0.8540000017092098, - queueing: 2.263999998831423, - ssl: 55.38700000033714, - dns: 3.559999997378327, - }, - }, - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'https://unpkg.com/director@1.2.8/build/director.js', - status: 200, - mimeType: 'application/javascript', - requestSentTime: 18098833.537, - requestStartTime: 18098837.233999997, - loadEndTime: 18098977.648000002, - timings: { - blocked: 84.54599999822676, - receive: 3.068000001803739, - queueing: 3.69700000010198, - proxy: -1, - total: 144.1110000014305, - wait: 52.56100000042352, - connect: -1, - send: 0.2390000008745119, - ssl: -1, - dns: -1, - }, - }, - ]; - - const networkItemsWithoutFullTimings: NetworkItems = [ - networkItems[0], - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', - status: 0, - mimeType: 'text/javascript', - requestSentTime: 18098834.097, - loadEndTime: 18098836.889999997, - timings: { - total: 2.7929999996558763, - blocked: -1, - ssl: -1, - wait: -1, - connect: -1, - dns: -1, - queueing: -1, - send: -1, - proxy: -1, - receive: -1, - }, - }, - ]; - - const networkItemsWithoutAnyTimings: NetworkItems = [ - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', - status: 0, - mimeType: 'text/javascript', - requestSentTime: 18098834.097, - loadEndTime: 18098836.889999997, - timings: { - total: -1, - blocked: -1, - ssl: -1, - wait: -1, - connect: -1, - dns: -1, - queueing: -1, - send: -1, - proxy: -1, - receive: -1, - }, - }, - ]; - - const networkItemsWithoutTimingsObject: NetworkItems = [ - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js', - status: 0, - mimeType: 'text/javascript', - requestSentTime: 18098834.097, - loadEndTime: 18098836.889999997, - }, - ]; - - const networkItemsWithUncommonMimeType: NetworkItems = [ - { - timestamp: '2021-01-05T19:22:28.928Z', - method: 'GET', - url: 'https://unpkg.com/director@1.2.8/build/director.js', - status: 200, - mimeType: 'application/x-javascript', - requestSentTime: 18098833.537, - requestStartTime: 18098837.233999997, - loadEndTime: 18098977.648000002, - timings: { - blocked: 84.54599999822676, - receive: 3.068000001803739, - queueing: 3.69700000010198, - proxy: -1, - total: 144.1110000014305, - wait: 52.56100000042352, - connect: -1, - send: 0.2390000008745119, - ssl: -1, - dns: -1, - }, - }, - ]; - it('formats timings', () => { const actual = getSeriesAndDomain(networkItems); expect(actual).toMatchInlineSnapshot(` @@ -175,6 +175,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#dcd4c4", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#dcd4c4", @@ -188,6 +189,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#54b399", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#54b399", @@ -201,6 +203,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#da8b45", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#da8b45", @@ -214,6 +217,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#edc5a2", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#edc5a2", @@ -227,6 +231,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#d36086", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#d36086", @@ -240,6 +245,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#b0c9e0", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#b0c9e0", @@ -253,6 +259,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#ca8eae", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#ca8eae", @@ -266,6 +273,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#dcd4c4", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#dcd4c4", @@ -279,6 +287,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#d36086", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#d36086", @@ -292,6 +301,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#b0c9e0", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#b0c9e0", @@ -305,6 +315,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#9170b8", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#9170b8", @@ -316,6 +327,7 @@ describe('getSeriesAndDomain', () => { "y0": 137.70799999925657, }, ], + "totalHighlightedRequests": 2, } `); }); @@ -332,6 +344,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#dcd4c4", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#dcd4c4", @@ -345,6 +358,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#54b399", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#54b399", @@ -358,6 +372,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#da8b45", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#da8b45", @@ -371,6 +386,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#edc5a2", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#edc5a2", @@ -384,6 +400,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#d36086", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#d36086", @@ -397,6 +414,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#b0c9e0", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#b0c9e0", @@ -410,6 +428,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#ca8eae", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#ca8eae", @@ -423,6 +442,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "#9170b8", + "isHighlighted": true, "showTooltip": true, "tooltipProps": Object { "colour": "#9170b8", @@ -434,6 +454,7 @@ describe('getSeriesAndDomain', () => { "y0": 0.9219999983906746, }, ], + "totalHighlightedRequests": 2, } `); }); @@ -450,6 +471,7 @@ describe('getSeriesAndDomain', () => { Object { "config": Object { "colour": "", + "isHighlighted": true, "showTooltip": false, "tooltipProps": undefined, }, @@ -458,6 +480,7 @@ describe('getSeriesAndDomain', () => { "y0": 0, }, ], + "totalHighlightedRequests": 1, } `); }); @@ -473,6 +496,7 @@ describe('getSeriesAndDomain', () => { "series": Array [ Object { "config": Object { + "isHighlighted": true, "showTooltip": false, }, "x": 0, @@ -480,6 +504,7 @@ describe('getSeriesAndDomain', () => { "y0": 0, }, ], + "totalHighlightedRequests": 1, } `); }); @@ -501,4 +526,41 @@ describe('getSeriesAndDomain', () => { }); expect(contentDownloadedingConfigItem).toBeDefined(); }); + + it('counts the total number of highlighted items', () => { + // only one CSS file in this array of network Items + const actual = getSeriesAndDomain(networkItems, false, '', ['stylesheet']); + expect(actual.totalHighlightedRequests).toBe(1); + }); + + it('adds isHighlighted to waterfall entry when filter matches', () => { + // only one CSS file in this array of network Items + const { series } = getSeriesAndDomain(networkItems, false, '', ['stylesheet']); + series.forEach((item) => { + if (item.x === 0) { + expect(item.config.isHighlighted).toBe(true); + } else { + expect(item.config.isHighlighted).toBe(false); + } + }); + }); + + it('adds isHighlighted to waterfall entry when query matches', () => { + // only the second item matches this query + const { series } = getSeriesAndDomain(networkItems, false, 'director', []); + series.forEach((item) => { + if (item.x === 1) { + expect(item.config.isHighlighted).toBe(true); + } else { + expect(item.config.isHighlighted).toBe(false); + } + }); + }); +}); + +describe('getSidebarItems', () => { + it('passes the item index offset by 1 to offsetIndex for visual display', () => { + const actual = getSidebarItems(networkItems, false, '', []); + expect(actual[0].offsetIndex).toBe(1); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index 0ac93794594c08..46f0d23d0a6b99 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -55,8 +55,28 @@ const getFriendlyTooltipValue = ({ } return `${label}: ${formatValueForDisplay(value)}ms`; }; +export const isHighlightedItem = ( + item: NetworkItem, + query?: string, + activeFilters: string[] = [] +) => { + if (!query && activeFilters?.length === 0) { + return true; + } + + const matchQuery = query ? item.url?.includes(query) : true; + const matchFilters = + activeFilters.length > 0 ? activeFilters.includes(MimeTypesMap[item.mimeType!]) : true; + + return !!(matchQuery && matchFilters); +}; -export const getSeriesAndDomain = (items: NetworkItems) => { +export const getSeriesAndDomain = ( + items: NetworkItems, + onlyHighlighted = false, + query?: string, + activeFilters?: string[] +) => { const getValueForOffset = (item: NetworkItem) => { return item.requestSentTime; }; @@ -78,13 +98,21 @@ export const getSeriesAndDomain = (items: NetworkItems) => { } }; + let totalHighlightedRequests = 0; + const series = items.reduce((acc, item, index) => { + const isHighlighted = isHighlightedItem(item, query, activeFilters); + if (isHighlighted) { + totalHighlightedRequests++; + } + if (!item.timings) { acc.push({ x: index, y0: 0, y: 0, config: { + isHighlighted, showTooltip: false, }, }); @@ -96,10 +124,13 @@ export const getSeriesAndDomain = (items: NetworkItems) => { let currentOffset = offsetValue - zeroOffset; + let timingValueFound = false; + TIMING_ORDER.forEach((timing) => { const value = getValue(item.timings, timing); - const colour = timing === Timings.Receive ? mimeTypeColour : colourPalette[timing]; if (value && value >= 0) { + timingValueFound = true; + const colour = timing === Timings.Receive ? mimeTypeColour : colourPalette[timing]; const y = currentOffset + value; acc.push({ @@ -108,6 +139,7 @@ export const getSeriesAndDomain = (items: NetworkItems) => { y, config: { colour, + isHighlighted, showTooltip: true, tooltipProps: { value: getFriendlyTooltipValue({ @@ -126,7 +158,7 @@ export const getSeriesAndDomain = (items: NetworkItems) => { /* if no specific timing values are found, use the total time * if total time is not available use 0, set showTooltip to false, * and omit tooltip props */ - if (!acc.find((entry) => entry.x === index)) { + if (!timingValueFound) { const total = item.timings.total; const hasTotal = total !== -1; acc.push({ @@ -134,6 +166,7 @@ export const getSeriesAndDomain = (items: NetworkItems) => { y0: hasTotal ? currentOffset : 0, y: hasTotal ? currentOffset + item.timings.total : 0, config: { + isHighlighted, colour: hasTotal ? mimeTypeColour : '', showTooltip: hasTotal, tooltipProps: hasTotal @@ -154,14 +187,31 @@ export const getSeriesAndDomain = (items: NetworkItems) => { const yValues = series.map((serie) => serie.y); const domain = { min: 0, max: Math.max(...yValues) }; - return { series, domain }; + + let filteredSeries = series; + if (onlyHighlighted) { + filteredSeries = series.filter((item) => item.config.isHighlighted); + } + + return { series: filteredSeries, domain, totalHighlightedRequests }; }; -export const getSidebarItems = (items: NetworkItems): SidebarItems => { - return items.map((item) => { +export const getSidebarItems = ( + items: NetworkItems, + onlyHighlighted: boolean, + query: string, + activeFilters: string[] +): SidebarItems => { + const sideBarItems = items.map((item, index) => { + const isHighlighted = isHighlightedItem(item, query, activeFilters); + const offsetIndex = index + 1; const { url, status, method } = item; - return { url, status, method }; + return { url, status, method, isHighlighted, offsetIndex }; }); + if (onlyHighlighted) { + return sideBarItems.filter((item) => item.isHighlighted); + } + return sideBarItems; }; export const getLegendItems = (): LegendItems => { @@ -184,6 +234,7 @@ export const getLegendItems = (): LegendItems => { { name: FriendlyMimetypeLabels[mimeType], colour: MIME_TYPE_PALETTE[mimeType] }, ]; }); + return [...timingItems, ...mimeTypeItems]; }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts index 8d261edc74bf4b..e22caae0d9eb2a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts @@ -61,16 +61,13 @@ export const TIMING_ORDER = [ Timings.Receive, ] as const; -export type CalculatedTimings = { - [K in Timings]?: number; -}; - export enum MimeType { Html = 'html', Script = 'script', Stylesheet = 'stylesheet', Media = 'media', Font = 'font', + XHR = 'xhr', Other = 'other', } @@ -99,6 +96,9 @@ export const FriendlyMimetypeLabels = { [MimeType.Font]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.mimeTypes.font', { defaultMessage: 'Font', }), + [MimeType.XHR]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.mimeTypes.xhr', { + defaultMessage: 'XHR', + }), [MimeType.Other]: i18n.translate( 'xpack.uptime.synthetics.waterfallChart.labels.mimeTypes.other', { @@ -112,7 +112,6 @@ export const FriendlyMimetypeLabels = { export const MimeTypesMap: Record = { 'text/html': MimeType.Html, 'application/javascript': MimeType.Script, - 'application/json': MimeType.Script, 'text/javascript': MimeType.Script, 'text/css': MimeType.Stylesheet, // Images @@ -146,38 +145,18 @@ export const MimeTypesMap: Record = { 'application/font-woff2': MimeType.Font, 'application/vnd.ms-fontobject': MimeType.Font, 'application/font-sfnt': MimeType.Font, + + // XHR + 'application/json': MimeType.XHR, }; export type NetworkItem = NetworkEvent; export type NetworkItems = NetworkItem[]; -// NOTE: A number will always be present if the property exists, but that number might be -1, which represents no value. -export interface PayloadTimings { - dns_start: number; - push_end: number; - worker_fetch_start: number; - worker_respond_with_settled: number; - proxy_end: number; - worker_start: number; - worker_ready: number; - send_end: number; - connect_end: number; - connect_start: number; - send_start: number; - proxy_start: number; - push_start: number; - ssl_end: number; - receive_headers_end: number; - ssl_start: number; - request_time: number; - dns_end: number; -} - -export interface ExtraSeriesConfig { - colour: string; -} - -export type SidebarItem = Pick; +export type SidebarItem = Pick & { + isHighlighted: boolean; + offsetIndex: number; +}; export type SidebarItems = SidebarItem[]; export interface LegendItem { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx new file mode 100644 index 00000000000000..e22f4a4c63f596 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx @@ -0,0 +1,248 @@ +/* + * 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 from 'react'; +import { act, fireEvent } from '@testing-library/react'; +import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; + +import { render } from '../../../../../lib/helper/rtl_helpers'; + +import { extractItems, isHighlightedItem } from './data_formatting'; + +import 'jest-canvas-mock'; +import { BAR_HEIGHT } from '../../waterfall/components/constants'; +import { MimeType } from './types'; +import { + FILTER_POPOVER_OPEN_LABEL, + FILTER_REQUESTS_LABEL, + FILTER_COLLAPSE_REQUESTS_LABEL, +} from '../../waterfall/components/translations'; + +const getHighLightedItems = (query: string, filters: string[]) => { + return NETWORK_EVENTS.events.filter((item) => isHighlightedItem(item, query, filters)); +}; + +describe('waterfall chart wrapper', () => { + jest.useFakeTimers(); + + it('renders the correct sidebar items', () => { + const { getAllByTestId } = render( + + ); + + const sideBarItems = getAllByTestId('middleTruncatedTextSROnly'); + + expect(sideBarItems).toHaveLength(5); + }); + + it('search by query works', () => { + const { getAllByTestId, getByTestId, getByLabelText } = render( + + ); + + const filterInput = getByLabelText(FILTER_REQUESTS_LABEL); + + const searchText = '.js'; + + fireEvent.change(filterInput, { target: { value: searchText } }); + + // inout has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + const highlightedItemsLength = getHighLightedItems(searchText, []).length; + expect(getAllByTestId('sideBarHighlightedItem')).toHaveLength(highlightedItemsLength); + + expect(getAllByTestId('sideBarDimmedItem')).toHaveLength( + NETWORK_EVENTS.events.length - highlightedItemsLength + ); + + const SIDE_BAR_ITEMS_HEIGHT = NETWORK_EVENTS.events.length * BAR_HEIGHT; + expect(getByTestId('wfSidebarContainer')).toHaveAttribute('height', `${SIDE_BAR_ITEMS_HEIGHT}`); + + expect(getByTestId('wfDataOnlyBarChart')).toHaveAttribute('height', `${SIDE_BAR_ITEMS_HEIGHT}`); + }); + + it('search by mime type works', () => { + const { getAllByTestId, getByLabelText, getAllByText } = render( + + ); + + const sideBarItems = getAllByTestId('middleTruncatedTextSROnly'); + + expect(sideBarItems).toHaveLength(5); + + fireEvent.click(getByLabelText(FILTER_POPOVER_OPEN_LABEL)); + + fireEvent.click(getAllByText('XHR')[1]); + + // inout has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + const highlightedItemsLength = getHighLightedItems('', [MimeType.XHR]).length; + + expect(getAllByTestId('sideBarHighlightedItem')).toHaveLength(highlightedItemsLength); + expect(getAllByTestId('sideBarDimmedItem')).toHaveLength( + NETWORK_EVENTS.events.length - highlightedItemsLength + ); + }); + + it('renders sidebar even when filter matches 0 resources', () => { + const { getAllByTestId, getByLabelText, getAllByText, queryAllByTestId } = render( + + ); + + const sideBarItems = getAllByTestId('middleTruncatedTextSROnly'); + + expect(sideBarItems).toHaveLength(5); + + fireEvent.click(getByLabelText(FILTER_POPOVER_OPEN_LABEL)); + + fireEvent.click(getAllByText('CSS')[1]); + + // inout has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + const highlightedItemsLength = getHighLightedItems('', [MimeType.Stylesheet]).length; + + // no CSS items found + expect(queryAllByTestId('sideBarHighlightedItem')).toHaveLength(0); + expect(getAllByTestId('sideBarDimmedItem')).toHaveLength( + NETWORK_EVENTS.events.length - highlightedItemsLength + ); + + fireEvent.click(getByLabelText(FILTER_COLLAPSE_REQUESTS_LABEL)); + + // filter bar is still accessible even when no resources match filter + expect(getByLabelText(FILTER_REQUESTS_LABEL)).toBeInTheDocument(); + + // no resources items are in the chart as none match filter + expect(queryAllByTestId('sideBarHighlightedItem')).toHaveLength(0); + expect(queryAllByTestId('sideBarDimmedItem')).toHaveLength(0); + }); +}); + +const NETWORK_EVENTS = { + events: [ + { + timestamp: '2021-01-21T10:31:21.537Z', + method: 'GET', + url: + 'https://apv-static.minute.ly/videos/v-c2a526c7-450d-428e-1244649-a390-fb639ffead96-s45.746-54.421m.mp4', + status: 206, + mimeType: 'video/mp4', + requestSentTime: 241114127.474, + requestStartTime: 241114129.214, + loadEndTime: 241116573.402, + timings: { + total: 2445.928000001004, + queueing: 1.7399999778717756, + blocked: 0.391999987186864, + receive: 2283.964000031119, + connect: 91.5709999972023, + wait: 28.795999998692423, + proxy: -1, + dns: 36.952000024029985, + send: 0.10000000474974513, + ssl: 64.28900000173599, + }, + }, + { + timestamp: '2021-01-21T10:31:22.174Z', + method: 'GET', + url: 'https://dpm.demdex.net/ibs:dpid=73426&dpuuid=31597189268188866891125449924942215949', + status: 200, + mimeType: 'image/gif', + requestSentTime: 241114749.202, + requestStartTime: 241114750.426, + loadEndTime: 241114805.541, + timings: { + queueing: 1.2240000069141388, + receive: 2.218999987235293, + proxy: -1, + dns: -1, + send: 0.14200000441633165, + blocked: 1.033000007737428, + total: 56.33900000248104, + wait: 51.72099999617785, + ssl: -1, + connect: -1, + }, + }, + { + timestamp: '2021-01-21T10:31:21.679Z', + method: 'GET', + url: 'https://dapi.cms.mlbinfra.com/v2/content/en-us/sel-t119-homepage-mediawall', + status: 200, + mimeType: 'application/json', + requestSentTime: 241114268.04299998, + requestStartTime: 241114270.184, + loadEndTime: 241114665.609, + timings: { + total: 397.5659999996424, + dns: 29.5429999823682, + wait: 221.6830000106711, + queueing: 2.1410000044852495, + connect: 106.95499999565072, + ssl: 69.06899999012239, + receive: 2.027999988058582, + blocked: 0.877000013133511, + send: 23.719999997410923, + proxy: -1, + }, + }, + { + timestamp: '2021-01-21T10:31:21.740Z', + method: 'GET', + url: 'https://platform.twitter.com/embed/embed.runtime.b313577971db9c857801.js', + status: 200, + mimeType: 'application/javascript', + requestSentTime: 241114303.84899998, + requestStartTime: 241114306.416, + loadEndTime: 241114370.361, + timings: { + send: 1.357000001007691, + wait: 40.12299998430535, + receive: 16.78500001435168, + ssl: -1, + queueing: 2.5670000177342445, + total: 66.51200001942925, + connect: -1, + blocked: 5.680000002030283, + proxy: -1, + dns: -1, + }, + }, + { + timestamp: '2021-01-21T10:31:21.740Z', + method: 'GET', + url: 'https://platform.twitter.com/embed/embed.modules.7a266e7acfd42f2581a5.js', + status: 200, + mimeType: 'application/javascript', + requestSentTime: 241114305.939, + requestStartTime: 241114310.393, + loadEndTime: 241114938.264, + timings: { + wait: 51.61500000394881, + dns: -1, + ssl: -1, + receive: 506.5750000067055, + proxy: -1, + connect: -1, + blocked: 69.51599998865277, + queueing: 4.453999979887158, + total: 632.324999984121, + send: 0.16500000492669642, + }, + }, + ], +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx index 91657981e7f890..8a0e9729a635b0 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx @@ -5,44 +5,14 @@ * 2.0. */ -import React, { useMemo, useState } from 'react'; -import { EuiHealth, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiHealth } from '@elastic/eui'; +import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public'; import { getSeriesAndDomain, getSidebarItems, getLegendItems } from './data_formatting'; import { SidebarItem, LegendItem, NetworkItems } from './types'; -import { - WaterfallProvider, - WaterfallChart, - MiddleTruncatedText, - RenderItem, -} from '../../waterfall'; - -export const renderSidebarItem: RenderItem = (item, index) => { - const { status } = item; - - const isErrorStatusCode = (statusCode: number) => { - const is400 = statusCode >= 400 && statusCode <= 499; - const is500 = statusCode >= 500 && statusCode <= 599; - const isSpecific300 = statusCode === 301 || statusCode === 307 || statusCode === 308; - return is400 || is500 || isSpecific300; - }; - - return ( - <> - {!status || !isErrorStatusCode(status) ? ( - - ) : ( - - - - - - {status} - - - )} - - ); -}; +import { WaterfallProvider, WaterfallChart, RenderItem } from '../../waterfall'; +import { WaterfallFilter } from './waterfall_filter'; +import { WaterfallSidebarItem } from './waterfall_sidebar_item'; export const renderLegendItem: RenderItem = (item) => { return {item.name}; @@ -54,23 +24,64 @@ interface Props { } export const WaterfallChartWrapper: React.FC = ({ data, total }) => { + const [query, setQuery] = useState(''); + const [activeFilters, setActiveFilters] = useState([]); + const [onlyHighlighted, setOnlyHighlighted] = useState(false); + const [networkData] = useState(data); - const { series, domain } = useMemo(() => { - return getSeriesAndDomain(networkData); - }, [networkData]); + const hasFilters = activeFilters.length > 0; + + const { series, domain, totalHighlightedRequests } = useMemo(() => { + return getSeriesAndDomain(networkData, onlyHighlighted, query, activeFilters); + }, [networkData, query, activeFilters, onlyHighlighted]); const sidebarItems = useMemo(() => { - return getSidebarItems(networkData); - }, [networkData]); + return getSidebarItems(networkData, onlyHighlighted, query, activeFilters); + }, [networkData, query, activeFilters, onlyHighlighted]); const legendItems = getLegendItems(); + const renderFilter = useCallback(() => { + return ( + + ); + }, [activeFilters, setActiveFilters, onlyHighlighted, setOnlyHighlighted, query, setQuery]); + + const renderSidebarItem: RenderItem = useCallback( + (item) => { + return ( + + ); + }, + [hasFilters, onlyHighlighted] + ); + + useTrackMetric({ app: 'uptime', metric: 'waterfall_chart_view', metricType: METRIC_TYPE.COUNT }); + useTrackMetric({ + app: 'uptime', + metric: 'waterfall_chart_view', + metricType: METRIC_TYPE.COUNT, + delay: 15000, + }); + return ( { @@ -81,10 +92,19 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { tickFormat={(d: number) => `${Number(d).toFixed(0)} ms`} domain={domain} barStyleAccessor={(datum) => { + if (!datum.datum.config.isHighlighted) { + return { + rect: { + fill: datum.datum.config.colour, + opacity: '0.1', + }, + }; + } return datum.datum.config.colour; }} renderSidebarItem={renderSidebarItem} renderLegendItem={renderLegendItem} + renderFilter={renderFilter} fullHeight={true} /> diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.test.tsx new file mode 100644 index 00000000000000..3acf6a269fb38f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.test.tsx @@ -0,0 +1,155 @@ +/* + * 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, { useState } from 'react'; +import { act, fireEvent } from '@testing-library/react'; + +import { render } from '../../../../../lib/helper/rtl_helpers'; + +import 'jest-canvas-mock'; +import { MIME_FILTERS, WaterfallFilter } from './waterfall_filter'; +import { + FILTER_REQUESTS_LABEL, + FILTER_COLLAPSE_REQUESTS_LABEL, + FILTER_POPOVER_OPEN_LABEL, +} from '../../waterfall/components/translations'; + +describe('waterfall filter', () => { + jest.useFakeTimers(); + + it('renders correctly', () => { + const { getByLabelText, getByTitle } = render( + + ); + + fireEvent.click(getByLabelText(FILTER_POPOVER_OPEN_LABEL)); + + MIME_FILTERS.forEach((filter) => { + expect(getByTitle(filter.label)); + }); + }); + + it('filter icon changes color on active/inactive filters', () => { + const Component = () => { + const [activeFilters, setActiveFilters] = useState([]); + + return ( + + ); + }; + const { getByLabelText, getByTitle } = render(); + + fireEvent.click(getByLabelText(FILTER_POPOVER_OPEN_LABEL)); + + fireEvent.click(getByTitle('XHR')); + + expect(getByLabelText(FILTER_POPOVER_OPEN_LABEL)).toHaveAttribute( + 'class', + 'euiButtonIcon euiButtonIcon--primary' + ); + + // toggle it back to inactive + fireEvent.click(getByTitle('XHR')); + + expect(getByLabelText(FILTER_POPOVER_OPEN_LABEL)).toHaveAttribute( + 'class', + 'euiButtonIcon euiButtonIcon--text' + ); + }); + + it('search input is working properly', () => { + const setQuery = jest.fn(); + + const Component = () => { + return ( + + ); + }; + const { getByLabelText } = render(); + + const testText = 'js'; + + fireEvent.change(getByLabelText(FILTER_REQUESTS_LABEL), { target: { value: testText } }); + + // inout has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(setQuery).toHaveBeenCalledWith(testText); + }); + + it('resets checkbox when filters are removed', () => { + const Component = () => { + const [onlyHighlighted, setOnlyHighlighted] = useState(false); + const [query, setQuery] = useState(''); + const [activeFilters, setActiveFilters] = useState([]); + return ( + + ); + }; + const { getByLabelText, getByTitle } = render(); + const input = getByLabelText(FILTER_REQUESTS_LABEL); + // apply filters + const testText = 'js'; + fireEvent.change(input, { target: { value: testText } }); + fireEvent.click(getByLabelText(FILTER_POPOVER_OPEN_LABEL)); + const filterGroupButton = getByTitle('XHR'); + fireEvent.click(filterGroupButton); + + // input has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + const collapseCheckbox = getByLabelText(FILTER_COLLAPSE_REQUESTS_LABEL) as HTMLInputElement; + expect(collapseCheckbox).not.toBeDisabled(); + fireEvent.click(collapseCheckbox); + expect(collapseCheckbox).toBeChecked(); + + // remove filters + fireEvent.change(input, { target: { value: '' } }); + fireEvent.click(filterGroupButton); + + // input has debounce effect so hence the timer + act(() => { + jest.advanceTimersByTime(300); + }); + + // expect the checkbox to reset to disabled and unchecked + expect(collapseCheckbox).not.toBeChecked(); + expect(collapseCheckbox).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.tsx new file mode 100644 index 00000000000000..42c2df4553b4c2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_filter.tsx @@ -0,0 +1,188 @@ +/* + * 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, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { + EuiButtonIcon, + EuiCheckbox, + EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSpacer, +} from '@elastic/eui'; +import useDebounce from 'react-use/lib/useDebounce'; +import { + FILTER_REQUESTS_LABEL, + FILTER_SCREENREADER_LABEL, + FILTER_REMOVE_SCREENREADER_LABEL, + FILTER_POPOVER_OPEN_LABEL, + FILTER_COLLAPSE_REQUESTS_LABEL, +} from '../../waterfall/components/translations'; +import { MimeType, FriendlyMimetypeLabels } from './types'; +import { METRIC_TYPE, useUiTracker } from '../../../../../../../observability/public'; + +interface Props { + query: string; + activeFilters: string[]; + setActiveFilters: Dispatch>; + setQuery: (val: string) => void; + onlyHighlighted: boolean; + setOnlyHighlighted: (val: boolean) => void; +} + +export const MIME_FILTERS = [ + { + label: FriendlyMimetypeLabels[MimeType.XHR], + mimeType: MimeType.XHR, + }, + { + label: FriendlyMimetypeLabels[MimeType.Html], + mimeType: MimeType.Html, + }, + { + label: FriendlyMimetypeLabels[MimeType.Script], + mimeType: MimeType.Script, + }, + { + label: FriendlyMimetypeLabels[MimeType.Stylesheet], + mimeType: MimeType.Stylesheet, + }, + { + label: FriendlyMimetypeLabels[MimeType.Font], + mimeType: MimeType.Font, + }, + { + label: FriendlyMimetypeLabels[MimeType.Media], + mimeType: MimeType.Media, + }, +]; + +export const WaterfallFilter = ({ + query, + setQuery, + activeFilters, + setActiveFilters, + onlyHighlighted, + setOnlyHighlighted, +}: Props) => { + const [value, setValue] = useState(query); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const trackMetric = useUiTracker({ app: 'uptime' }); + + const toggleFilters = (val: string) => { + setActiveFilters((prevState) => + prevState.includes(val) ? prevState.filter((filter) => filter !== val) : [...prevState, val] + ); + }; + useDebounce( + () => { + setQuery(value); + }, + 250, + [value] + ); + + /* reset checkbox when there is no query or active filters + * this prevents the checkbox from being checked in a disabled state */ + useEffect(() => { + if (!(query || activeFilters.length > 0)) { + setOnlyHighlighted(false); + } + }, [activeFilters.length, setOnlyHighlighted, query]); + + // indicates use of the query input box + useEffect(() => { + if (query) { + trackMetric({ metric: 'waterfall_filter_input_changed', metricType: METRIC_TYPE.CLICK }); + } + }, [query, trackMetric]); + + // indicates the collapse to show only highlighted checkbox has been clicked + useEffect(() => { + if (onlyHighlighted) { + trackMetric({ + metric: 'waterfall_filter_collapse_checked', + metricType: METRIC_TYPE.CLICK, + }); + } + }, [onlyHighlighted, trackMetric]); + + // indicates filters have been applied or changed + useEffect(() => { + if (activeFilters.length > 0) { + trackMetric({ + metric: `waterfall_filters_applied_changed`, + metricType: METRIC_TYPE.CLICK, + }); + } + }, [activeFilters, trackMetric]); + + return ( + + + { + setValue(evt.target.value); + }} + value={value} + /> + + + setIsPopoverOpen((prevState) => !prevState)} + color={activeFilters.length > 0 ? 'primary' : 'text'} + isSelected={activeFilters.length > 0} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + anchorPosition="rightCenter" + > + + {MIME_FILTERS.map(({ label, mimeType }) => ( + toggleFilters(mimeType)} + key={label} + withNext={true} + aria-label={`${ + activeFilters.includes(mimeType) + ? FILTER_REMOVE_SCREENREADER_LABEL + : FILTER_SCREENREADER_LABEL + } ${label}`} + > + {label} + + ))} + + + 0)} + id="onlyHighlighted" + label={FILTER_COLLAPSE_REQUESTS_LABEL} + checked={onlyHighlighted} + onChange={(e) => { + setOnlyHighlighted(e.target.checked); + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx new file mode 100644 index 00000000000000..25b577ef9403aa --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx @@ -0,0 +1,56 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { SidebarItem } from '../waterfall/types'; +import { MiddleTruncatedText } from '../../waterfall'; +import { SideBarItemHighlighter } from '../../waterfall/components/styles'; +import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; + +interface SidebarItemProps { + item: SidebarItem; + renderFilterScreenReaderText?: boolean; +} + +export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: SidebarItemProps) => { + const { status, offsetIndex, isHighlighted } = item; + + const isErrorStatusCode = (statusCode: number) => { + const is400 = statusCode >= 400 && statusCode <= 499; + const is500 = statusCode >= 500 && statusCode <= 599; + const isSpecific300 = statusCode === 301 || statusCode === 307 || statusCode === 308; + return is400 || is500 || isSpecific300; + }; + + const text = `${offsetIndex}. ${item.url}`; + const ariaLabel = `${ + isHighlighted && renderFilterScreenReaderText + ? `${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ` + : '' + }${text}`; + + return ( + + {!status || !isErrorStatusCode(status) ? ( + + ) : ( + + + + + + {status} + + + )} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx new file mode 100644 index 00000000000000..578d66a1ea3f1d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 from 'react'; +import { SidebarItem } from '../waterfall/types'; + +import { render } from '../../../../../lib/helper/rtl_helpers'; + +import 'jest-canvas-mock'; +import { WaterfallSidebarItem } from './waterfall_sidebar_item'; +import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; + +describe('waterfall filter', () => { + const url = 'http://www.elastic.co'; + const offsetIndex = 1; + const item: SidebarItem = { + url, + isHighlighted: true, + offsetIndex, + }; + + it('renders sidbar item', () => { + const { getByText } = render(); + + expect(getByText(`${offsetIndex}. ${url}`)); + }); + + it('render screen reader text when renderFilterScreenReaderText is true', () => { + const { getByLabelText } = render( + + ); + + expect( + getByLabelText(`${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ${offsetIndex}. ${url}`) + ).toBeInTheDocument(); + }); + + it('does not render screen reader text when renderFilterScreenReaderText is false', () => { + const { queryByLabelText } = render( + + ); + + expect( + queryByLabelText(`${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ${offsetIndex}. ${url}`) + ).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts index 543d6004b8955b..a4b75174543a81 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts @@ -17,3 +17,5 @@ export const FIXED_AXIS_HEIGHT = 32; // number of items to display in canvas, since canvas can only have limited size export const CANVAS_MAX_ITEMS = 150; + +export const CHART_LEGEND_PADDING = 62; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx index 9a3d4efb63a3a8..d6c1d777a40a78 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx @@ -25,15 +25,21 @@ describe('getChunks', () => { }); describe('Component', () => { - it('renders truncated text', () => { - const { getByText } = render(); + it('renders truncated text and aria label', () => { + const { getByText, getByLabelText } = render( + + ); expect(getByText(first)).toBeInTheDocument(); expect(getByText(last)).toBeInTheDocument(); + + expect(getByLabelText(longString)).toBeInTheDocument(); }); it('renders screen reader only text', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); const { getByText } = within(getByTestId('middleTruncatedTextSROnly')); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index 9c263312f78f54..ec363ed2b40a4e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -10,6 +10,11 @@ import styled from 'styled-components'; import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui'; import { FIXED_AXIS_HEIGHT } from './constants'; +interface Props { + ariaLabel: string; + text: string; +} + const OuterContainer = styled.div` width: 100%; height: 100%; @@ -50,14 +55,14 @@ export const getChunks = (text: string) => { // Helper component for adding middle text truncation, e.g. // really-really-really-long....ompressed.js // Can be used to accomodate content in sidebar item rendering. -export const MiddleTruncatedText = ({ text }: { text: string }) => { +export const MiddleTruncatedText = ({ ariaLabel, text }: Props) => { const chunks = useMemo(() => { return getChunks(text); }, [text]); return ( <> - + {text} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.test.tsx index f46bab8c33a85b..63b4d2945a51c3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.test.tsx @@ -12,7 +12,11 @@ import { render } from '../../../../../lib/helper/rtl_helpers'; describe('NetworkRequestsTotal', () => { it('message in case total is greater than fetched', () => { const { getByText, getByLabelText } = render( - + ); expect(getByText('First 1000/1100 network requests')).toBeInTheDocument(); @@ -21,9 +25,52 @@ describe('NetworkRequestsTotal', () => { it('message in case total is equal to fetched requests', () => { const { getByText } = render( - + ); expect(getByText('500 network requests')).toBeInTheDocument(); }); + + it('does not show highlighted item message when showHighlightedNetworkEvents is false', () => { + const { queryByText } = render( + + ); + + expect(queryByText(/match the filter/)).not.toBeInTheDocument(); + }); + + it('does not show highlighted item message when highlightedNetworkEvents is less than 0', () => { + const { queryByText } = render( + + ); + + expect(queryByText(/match the filter/)).not.toBeInTheDocument(); + }); + + it('show highlighted item message when highlightedNetworkEvents is greater than 0 and showHighlightedNetworkEvents is true', () => { + const { getByText } = render( + + ); + + expect(getByText(/\(20 match the filter\)/)).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.tsx index fce86c6b5c29d8..5ccd60b0ce7a88 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/network_requests_total.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiIconTip } from '@elastic/eui'; import { NetworkRequestsTotalStyle } from './styles'; @@ -13,24 +14,44 @@ import { NetworkRequestsTotalStyle } from './styles'; interface Props { totalNetworkRequests: number; fetchedNetworkRequests: number; + highlightedNetworkRequests: number; + showHighlightedNetworkRequests?: boolean; } -export const NetworkRequestsTotal = ({ totalNetworkRequests, fetchedNetworkRequests }: Props) => { +export const NetworkRequestsTotal = ({ + totalNetworkRequests, + fetchedNetworkRequests, + highlightedNetworkRequests, + showHighlightedNetworkRequests, +}: Props) => { return ( - {i18n.translate('xpack.uptime.synthetics.waterfall.requestsTotalMessage', { - defaultMessage: '{numNetworkRequests} network requests', - values: { + fetchedNetworkRequests - ? i18n.translate('xpack.uptime.synthetics.waterfall.requestsTotalMessage.first', { - defaultMessage: 'First {count}', - values: { count: `${fetchedNetworkRequests}/${totalNetworkRequests}` }, - }) - : totalNetworkRequests, - }, - })} + totalNetworkRequests > fetchedNetworkRequests ? ( + + ) : ( + totalNetworkRequests + ), + }} + />{' '} + {showHighlightedNetworkRequests && highlightedNetworkRequests >= 0 && ( + + )} {totalNetworkRequests > fetchedNetworkRequests && ( = ({ items, render }) => { return ( - + - {items.map((item, index) => { - return ( - - {render(item, index)} - - ); - })} + {items.map((item) => ( + + {render(item)} + + ))} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 333acd6e043df7..c00c04b1140450 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -14,10 +14,7 @@ interface WaterfallChartOuterContainerProps { height?: string; } -export const WaterfallChartOuterContainer = euiStyled.div` - height: ${(props) => (props.height ? `${props.height}` : 'auto')}; - overflow-y: ${(props) => (props.height ? 'scroll' : 'visible')}; - overflow-x: hidden; +const StyledScrollDiv = euiStyled.div` &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; width: ${({ theme }) => theme.eui.euiScrollBar}; @@ -33,11 +30,27 @@ export const WaterfallChartOuterContainer = euiStyled.div` + height: ${(props) => (props.height ? `${props.height}` : 'auto')}; + overflow-y: ${(props) => (props.height ? 'scroll' : 'visible')}; + overflow-x: hidden; +`; + +export const WaterfallChartFixedTopContainer = euiStyled(StyledScrollDiv)` position: sticky; top: 0; z-index: ${(props) => props.theme.eui.euiZLevel4}; - border-bottom: ${(props) => `1px solid ${props.theme.eui.euiColorLightShade}`}; + overflow-y: scroll; + overflow-x: hidden; +`; + +export const WaterfallChartAxisOnlyContainer = euiStyled(EuiFlexItem)` + margin-left: -22px; +`; + +export const WaterfallChartTopContainer = euiStyled(EuiFlexGroup)` `; export const WaterfallChartFixedTopContainerSidebarCover = euiStyled(EuiPanel)` @@ -46,9 +59,18 @@ export const WaterfallChartFixedTopContainerSidebarCover = euiStyled(EuiPanel)` border: none; `; // NOTE: border-radius !important is here as the "border" prop isn't working +export const WaterfallChartFilterContainer = euiStyled.div` + && { + padding: 16px; + z-index: ${(props) => props.theme.eui.euiZLevel5}; + border-bottom: 0.3px solid ${(props) => props.theme.eui.euiColorLightShade}; + } +`; // NOTE: border-radius !important is here as the "border" prop isn't working + export const WaterfallChartFixedAxisContainer = euiStyled.div` height: ${FIXED_AXIS_HEIGHT}px; z-index: ${(props) => props.theme.eui.euiZLevel4}; + height: 100%; `; interface WaterfallChartSidebarContainer { @@ -74,6 +96,12 @@ export const WaterfallChartSidebarFlexItem = euiStyled(EuiFlexItem)` min-width: 0; padding-left: ${(props) => props.theme.eui.paddingSizes.m}; padding-right: ${(props) => props.theme.eui.paddingSizes.m}; + z-index: ${(props) => props.theme.eui.euiZLevel4}; +`; + +export const SideBarItemHighlighter = euiStyled.span<{ isHighlighted: boolean }>` + opacity: ${(props) => (props.isHighlighted ? 1 : 0.4)}; + height: 100%; `; interface WaterfallChartChartContainer { @@ -106,6 +134,12 @@ export const WaterfallChartTooltip = euiStyled.div` `; export const NetworkRequestsTotalStyle = euiStyled(EuiText)` - line-height: ${FIXED_AXIS_HEIGHT}px; - margin-left: ${(props) => props.theme.eui.paddingSizes.m} + line-height: 28px; + padding: 0 ${(props) => props.theme.eui.paddingSizes.m}; + border-bottom: 0.3px solid ${(props) => props.theme.eui.euiColorLightShade}; + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +export const RelativeContainer = euiStyled.div` + position: relative; `; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/translations.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/translations.ts new file mode 100644 index 00000000000000..b63ffacaadd2e9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/translations.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FILTER_REQUESTS_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.searchBox.placeholder', + { + defaultMessage: 'Filter network requests', + } +); + +export const FILTER_SCREENREADER_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.filterGroup.filterScreenreaderLabel', + { + defaultMessage: 'Filter by', + } +); + +export const FILTER_REMOVE_SCREENREADER_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.filterGroup.removeFilterScreenReaderLabel', + { + defaultMessage: 'Remove filter by', + } +); + +export const FILTER_POPOVER_OPEN_LABEL = i18n.translate( + 'xpack.uptime.pingList.synthetics.waterfall.filters.popover', + { + defaultMessage: 'Click to open waterfall filters', + } +); + +export const FILTER_COLLAPSE_REQUESTS_LABEL = i18n.translate( + 'xpack.uptime.pingList.synthetics.waterfall.filters.collapseRequestsLabel', + { + defaultMessage: 'Collapse to only show matching requests', + } +); + +export const SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL = i18n.translate( + 'xpack.uptime.synthetics.waterfall.sidebar.filterMatchesScreenReaderLabel', + { + defaultMessage: 'Resource matches filter', + } +); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx index 1ce46fc0d6e7b5..a963fb1e2939c7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx @@ -10,9 +10,14 @@ import { renderHook } from '@testing-library/react-hooks'; import { IWaterfallContext } from '../context/waterfall_chart'; import { CANVAS_MAX_ITEMS } from './constants'; -const generateTestData = (): IWaterfallContext['data'] => { +const generateTestData = ( + { + xMultiplier, + }: { + xMultiplier: number; + } = { xMultiplier: 1 } +): IWaterfallContext['data'] => { const numberOfItems = 1000; - const data: IWaterfallContext['data'] = []; const testItem = { x: 0, @@ -29,11 +34,11 @@ const generateTestData = (): IWaterfallContext['data'] => { data.push( { ...testItem, - x: i, + x: xMultiplier * i, }, { ...testItem, - x: i, + x: xMultiplier * i, y0: 7, y: 25, } @@ -44,7 +49,7 @@ const generateTestData = (): IWaterfallContext['data'] => { }; describe('useBarChartsHooks', () => { - it('returns result as expected', () => { + it('returns result as expected for non filtered data', () => { const { result, rerender } = renderHook((props) => useBarCharts(props), { initialProps: { data: [] as IWaterfallContext['data'] }, }); @@ -70,4 +75,35 @@ describe('useBarChartsHooks', () => { expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4); expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1); }); + + it('returns result as expected for filtered data', () => { + /* multiply x values to simulate filtered data, where x values can have gaps in the + * sequential order */ + const xMultiplier = 2; + const { result, rerender } = renderHook((props) => useBarCharts(props), { + initialProps: { data: [] as IWaterfallContext['data'] }, + }); + + expect(result.current).toHaveLength(0); + const newData = generateTestData({ xMultiplier }); + + rerender({ data: newData }); + + // Thousands items will result in 7 Canvas + expect(result.current.length).toBe(7); + + const firstChartItems = result.current[0]; + const lastChartItems = result.current[4]; + + // first chart items last item should be x 149, since we only display 150 items + expect(firstChartItems[firstChartItems.length - 1].x).toBe( + (CANVAS_MAX_ITEMS - 1) * xMultiplier + ); + + // since here are 5 charts, last chart first item should be x 600 + expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4 * xMultiplier); + expect(lastChartItems[lastChartItems.length - 1].x).toBe( + (CANVAS_MAX_ITEMS * 5 - 1) * xMultiplier + ); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts index 79fd437039afed..2baf8955049113 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts @@ -13,27 +13,36 @@ export interface UseBarHookProps { data: IWaterfallContext['data']; } -export const useBarCharts = ({ data = [] }: UseBarHookProps) => { +export const useBarCharts = ({ data }: UseBarHookProps) => { const [charts, setCharts] = useState>([]); useEffect(() => { - if (data.length > 0) { - let chartIndex = 0; - - const chartsN: Array = []; + const chartsN: Array = []; + if (data?.length > 0) { + let chartIndex = 0; + /* We want at most CANVAS_MAX_ITEMS **RESOURCES** per array. + * Resources !== individual timing items, but are comprised of many individual timing + * items. The X value of each item can be used as an id for the resource. + * We must keep track of the number of unique resources added to the each array. */ + const uniqueResources = new Set(); + let lastIndex: number; data.forEach((item) => { - // Subtract 1 to account for x value starting from 0 - if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) { - chartsN.push([item]); + if (uniqueResources.size === CANVAS_MAX_ITEMS && item.x > lastIndex) { chartIndex++; + uniqueResources.clear(); + } + uniqueResources.add(item.x); + lastIndex = item.x; + if (!chartsN[chartIndex]) { + chartsN.push([item]); return; } - chartsN[chartIndex - 1].push(item); + chartsN[chartIndex].push(item); }); - - setCharts(chartsN); } + + setCharts(chartsN); }, [data]); return charts; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall.test.tsx index 7c9051e8f6acfe..528d749f576fce 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall.test.tsx @@ -6,64 +6,38 @@ */ import React from 'react'; -import { of } from 'rxjs'; -import { MountWithReduxProvider, mountWithRouter } from '../../../../../lib'; -import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public'; import { WaterfallChart } from './waterfall_chart'; -import { - renderLegendItem, - renderSidebarItem, -} from '../../step_detail/waterfall/waterfall_chart_wrapper'; -import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { WaterfallChartOuterContainer } from './styles'; +import { renderLegendItem } from '../../step_detail/waterfall/waterfall_chart_wrapper'; +import { render } from '../../../../../lib/helper/rtl_helpers'; + +import 'jest-canvas-mock'; describe('waterfall', () => { it('sets the correct height in case of full height', () => { - const core = mockCore(); - const Component = () => { return ( - `${Number(d).toFixed(0)} ms`} - domain={{ - max: 3371, - min: 0, - }} - barStyleAccessor={(datum) => { - return datum.datum.config.colour; - }} - renderSidebarItem={renderSidebarItem} - renderLegendItem={renderLegendItem} - fullHeight={true} - /> +
+ `${Number(d).toFixed(0)} ms`} + domain={{ + max: 3371, + min: 0, + }} + barStyleAccessor={(datum) => { + return datum.datum.config.colour; + }} + renderSidebarItem={undefined} + renderLegendItem={renderLegendItem} + fullHeight={true} + /> +
); }; - const component = mountWithRouter( - - - - - - - - ); + const { getByTestId } = render(); - const chartWrapper = component.find(WaterfallChartOuterContainer); + const chartWrapper = getByTestId('waterfallOuterContainer'); - expect(chartWrapper.get(0).props.height).toBe('calc(100vh - 0px)'); + expect(chartWrapper).toHaveStyleRule('height', 'calc(100vh - 62px)'); }); }); - -const mockCore: () => any = () => { - return { - application: { - getUrlForApp: () => '/app/uptime', - navigateToUrl: jest.fn(), - }, - uiSettings: { - get: (key: string) => 'MMM D, YYYY @ HH:mm:ss.SSS', - get$: (key: string) => of('MMM D, YYYY @ HH:mm:ss.SSS'), - }, - }; -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx new file mode 100644 index 00000000000000..df00df147fc6c5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -0,0 +1,112 @@ +/* + * 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 from 'react'; +import { + Axis, + BarSeries, + BarStyleAccessor, + Chart, + DomainRange, + Position, + ScaleType, + Settings, + TickFormatter, + TooltipInfo, +} from '@elastic/charts'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { BAR_HEIGHT } from './constants'; +import { useChartTheme } from '../../../../../hooks/use_chart_theme'; +import { WaterfallChartChartContainer, WaterfallChartTooltip } from './styles'; +import { useWaterfallContext, WaterfallData } from '..'; + +const getChartHeight = (data: WaterfallData): number => { + // We get the last item x(number of bars) and adds 1 to cater for 0 index + const noOfXBars = new Set(data.map((item) => item.x)).size; + + return noOfXBars * BAR_HEIGHT; +}; + +const Tooltip = (tooltipInfo: TooltipInfo) => { + const { data, renderTooltipItem } = useWaterfallContext(); + const relevantItems = data.filter((item) => { + return ( + item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps + ); + }); + return relevantItems.length ? ( + + + {relevantItems.map((item, index) => { + return ( + {renderTooltipItem(item.config.tooltipProps)} + ); + })} + + + ) : null; +}; + +interface Props { + index: number; + chartData: WaterfallData; + tickFormat: TickFormatter; + domain: DomainRange; + barStyleAccessor: BarStyleAccessor; +} + +export const WaterfallBarChart = ({ + chartData, + tickFormat, + domain, + barStyleAccessor, + index, +}: Props) => { + const theme = useChartTheme(); + + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx index 8f831d0629b25c..e0e5165b41e498 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx @@ -5,62 +5,30 @@ * 2.0. */ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { - Axis, - BarSeries, - Chart, - Position, - ScaleType, - Settings, - TickFormatter, - DomainRange, - BarStyleAccessor, - TooltipInfo, - TooltipType, -} from '@elastic/charts'; -import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; -// NOTE: The WaterfallChart has a hard requirement that consumers / solutions are making use of KibanaReactContext, and useKibana etc -// can therefore be accessed. -import { useUiSetting$ } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { TickFormatter, DomainRange, BarStyleAccessor } from '@elastic/charts'; + import { useWaterfallContext } from '../context/waterfall_chart'; import { WaterfallChartOuterContainer, WaterfallChartFixedTopContainer, WaterfallChartFixedTopContainerSidebarCover, - WaterfallChartFixedAxisContainer, - WaterfallChartChartContainer, - WaterfallChartTooltip, + WaterfallChartTopContainer, + RelativeContainer, + WaterfallChartFilterContainer, + WaterfallChartAxisOnlyContainer, } from './styles'; -import { WaterfallData } from '../types'; -import { BAR_HEIGHT, CANVAS_MAX_ITEMS, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE } from './constants'; +import { CHART_LEGEND_PADDING, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE } from './constants'; import { Sidebar } from './sidebar'; import { Legend } from './legend'; import { useBarCharts } from './use_bar_charts'; +import { WaterfallBarChart } from './waterfall_bar_chart'; +import { WaterfallChartFixedAxis } from './waterfall_chart_fixed_axis'; import { NetworkRequestsTotal } from './network_requests_total'; -const Tooltip = (tooltipInfo: TooltipInfo) => { - const { data, renderTooltipItem } = useWaterfallContext(); - const relevantItems = data.filter((item) => { - return ( - item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps - ); - }); - return relevantItems.length ? ( - - - {relevantItems.map((item, index) => { - return ( - {renderTooltipItem(item.config.tooltipProps)} - ); - })} - - - ) : null; -}; - -export type RenderItem = (item: I, index: number) => JSX.Element; +export type RenderItem = (item: I, index?: number) => JSX.Element; +export type RenderFilter = () => JSX.Element; export interface WaterfallChartProps { tickFormat: TickFormatter; @@ -68,159 +36,100 @@ export interface WaterfallChartProps { barStyleAccessor: BarStyleAccessor; renderSidebarItem?: RenderItem; renderLegendItem?: RenderItem; + renderFilter?: RenderFilter; maxHeight?: string; fullHeight?: boolean; } -const getChartHeight = (data: WaterfallData, ind: number): number => { - // We get the last item x(number of bars) and adds 1 to cater for 0 index - return (data[data.length - 1]?.x + 1 - ind * CANVAS_MAX_ITEMS) * BAR_HEIGHT; -}; - export const WaterfallChart = ({ tickFormat, domain, barStyleAccessor, renderSidebarItem, renderLegendItem, + renderFilter, maxHeight = '800px', fullHeight = false, }: WaterfallChartProps) => { const { data, + showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, totalNetworkRequests, + highlightedNetworkRequests, fetchedNetworkRequests, } = useWaterfallContext(); - const [darkMode] = useUiSetting$('theme:darkMode'); - - const theme = useMemo(() => { - return darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; - }, [darkMode]); - const chartWrapperDivRef = useRef(null); const [height, setHeight] = useState(maxHeight); - const shouldRenderSidebar = !!(sidebarItems && sidebarItems.length > 0 && renderSidebarItem); + const shouldRenderSidebar = !!(sidebarItems && renderSidebarItem); const shouldRenderLegend = !!(legendItems && legendItems.length > 0 && renderLegendItem); useEffect(() => { if (fullHeight && chartWrapperDivRef.current) { const chartOffset = chartWrapperDivRef.current.getBoundingClientRect().top; - setHeight(`calc(100vh - ${chartOffset}px)`); + setHeight(`calc(100vh - ${chartOffset + CHART_LEGEND_PADDING}px)`); } }, [chartWrapperDivRef, fullHeight]); const chartsToDisplay = useBarCharts({ data }); return ( - - <> - - - {shouldRenderSidebar && ( - - - - - - )} - - - - - - - - - - + + + + {shouldRenderSidebar && ( + + + + {renderFilter && ( + {renderFilter()} + )} - - - + )} + + + + + + + + {shouldRenderSidebar && } - + + {chartsToDisplay.map((chartData, ind) => ( - - - - - - - - - + chartData={chartData} + domain={domain} + barStyleAccessor={barStyleAccessor} + tickFormat={tickFormat} + /> ))} - + - {shouldRenderLegend && } - - + + {shouldRenderLegend && } + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx new file mode 100644 index 00000000000000..3a7ab421b6277b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx @@ -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 React from 'react'; +import { + Axis, + BarSeries, + BarStyleAccessor, + Chart, + DomainRange, + Position, + ScaleType, + Settings, + TickFormatter, + TooltipType, +} from '@elastic/charts'; +import { useChartTheme } from '../../../../../hooks/use_chart_theme'; +import { WaterfallChartFixedAxisContainer } from './styles'; + +interface Props { + tickFormat: TickFormatter; + domain: DomainRange; + barStyleAccessor: BarStyleAccessor; +} + +export const WaterfallChartFixedAxis = ({ tickFormat, domain, barStyleAccessor }: Props) => { + const theme = useChartTheme(); + + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx index 68d24514a37d3e..9e87d69ce38a82 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx @@ -7,12 +7,15 @@ import React, { createContext, useContext, Context } from 'react'; import { WaterfallData, WaterfallDataEntry } from '../types'; +import { SidebarItems } from '../../step_detail/waterfall/types'; export interface IWaterfallContext { totalNetworkRequests: number; + highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: WaterfallData; - sidebarItems?: unknown[]; + showOnlyHighlightedNetworkRequests: boolean; + sidebarItems?: SidebarItems; legendItems?: unknown[]; renderTooltipItem: ( item: WaterfallDataEntry['config']['tooltipProps'], @@ -24,8 +27,10 @@ export const WaterfallContext = createContext>({}); interface ProviderProps { totalNetworkRequests: number; + highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: IWaterfallContext['data']; + showOnlyHighlightedNetworkRequests: IWaterfallContext['showOnlyHighlightedNetworkRequests']; sidebarItems?: IWaterfallContext['sidebarItems']; legendItems?: IWaterfallContext['legendItems']; renderTooltipItem: IWaterfallContext['renderTooltipItem']; @@ -34,20 +39,24 @@ interface ProviderProps { export const WaterfallProvider: React.FC = ({ children, data, + showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, renderTooltipItem, totalNetworkRequests, + highlightedNetworkRequests, fetchedNetworkRequests, }) => { return ( diff --git a/x-pack/plugins/uptime/public/hooks/use_chart_theme.ts b/x-pack/plugins/uptime/public/hooks/use_chart_theme.ts new file mode 100644 index 00000000000000..f9231abaa75a80 --- /dev/null +++ b/x-pack/plugins/uptime/public/hooks/use_chart_theme.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { useMemo } from 'react'; +import { useUiSetting$ } from '../../../../../src/plugins/kibana_react/public'; + +export const useChartTheme = () => { + const [darkMode] = useUiSetting$('theme:darkMode'); + + const theme = useMemo(() => { + return darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + }, [darkMode]); + + return theme; +}; diff --git a/x-pack/plugins/uptime/public/lib/helper/enzyme_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/enzyme_helpers.tsx index 9656c63274a13e..4c81247fb2cf16 100644 --- a/x-pack/plugins/uptime/public/lib/helper/enzyme_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/enzyme_helpers.tsx @@ -8,10 +8,17 @@ import React, { ReactElement } from 'react'; import { Router } from 'react-router-dom'; import { MemoryHistory } from 'history/createMemoryHistory'; -import { createMemoryHistory } from 'history'; +import { createMemoryHistory, History } from 'history'; import { mountWithIntl, renderWithIntl, shallowWithIntl } from '@kbn/test/jest'; import { MountWithReduxProvider } from './helper_with_redux'; import { AppState } from '../../state'; +import { mockState } from '../__mocks__/uptime_store.mock'; +import { KibanaProviderOptions, MockRouter } from './rtl_helpers'; + +interface RenderRouterOptions extends KibanaProviderOptions { + history?: History; + state?: Partial; +} const helperWithRouter: ( helper: (node: ReactElement) => R, @@ -67,3 +74,39 @@ export const mountWithRouterRedux = ( options?.storeState ); }; + +/* Custom enzyme render */ +export function render( + ui: ReactElement, + { history, core, kibanaProps, state }: RenderRouterOptions = {} +) { + const testState: AppState = { + ...mockState, + ...state, + }; + return renderWithIntl( + + + {ui} + + + ); +} + +/* Custom enzyme render */ +export function mount( + ui: ReactElement, + { history, core, kibanaProps, state }: RenderRouterOptions = {} +) { + const testState: AppState = { + ...mockState, + ...state, + }; + return mountWithIntl( + + + {ui} + + + ); +} diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index abc0451bf8efad..e02a2c6f9832f6 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -6,6 +6,7 @@ */ import React, { ReactElement } from 'react'; +import { of } from 'rxjs'; import { render as reactTestLibRender, RenderOptions } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory, History } from 'history'; @@ -26,7 +27,7 @@ interface KibanaProps { services?: KibanaServices; } -interface KibanaProviderOptions { +export interface KibanaProviderOptions { core?: Partial & ExtraCore; kibanaProps?: KibanaProps; } @@ -54,6 +55,11 @@ const mockCore: () => any = () => { getUrlForApp: () => '/app/uptime', navigateToUrl: jest.fn(), }, + uiSettings: { + get: (key: string) => 'MMM D, YYYY @ HH:mm:ss.SSS', + get$: (key: string) => of('MMM D, YYYY @ HH:mm:ss.SSS'), + }, + usageCollection: { reportUiCounter: () => {} }, }; return core; From 31a3ec5934b0eb566d66797d43518933599fbfdc Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 8 Feb 2021 15:31:09 -0500 Subject: [PATCH 13/18] [Time To Visualize] Make State Transfer App Specific (#89804) * made state transfer app specific --- ...mbeddablestatetransfer.cleareditorstate.md | 11 ++- ...blestatetransfer.getincomingeditorstate.md | 5 +- ...tetransfer.getincomingembeddablepackage.md | 5 +- ...beddable-public.embeddablestatetransfer.md | 6 +- .../hooks/use_dashboard_container.ts | 6 +- .../embeddable_state_transfer.test.ts | 98 ++++++++++++++++--- .../embeddable_state_transfer.ts | 39 ++++++-- src/plugins/embeddable/public/public.api.md | 7 +- .../components/visualize_byvalue_editor.tsx | 2 +- .../components/visualize_editor.tsx | 4 +- .../components/visualize_listing.tsx | 2 +- .../application/utils/get_top_nav_config.tsx | 2 +- .../public/application/visualize_constants.ts | 1 + src/plugins/visualize/public/plugin.ts | 6 +- x-pack/plugins/lens/common/constants.ts | 1 + x-pack/plugins/lens/public/app_plugin/app.tsx | 4 +- .../lens/public/app_plugin/mounter.tsx | 4 +- x-pack/plugins/lens/public/plugin.ts | 4 +- x-pack/plugins/maps/public/render_app.tsx | 3 +- .../routes/list_page/load_list_and_render.tsx | 4 +- .../routes/map_page/saved_map/saved_map.ts | 4 +- 21 files changed, 162 insertions(+), 56 deletions(-) diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md index 5c1a6a0393c2e6..034f9c70e389fe 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md @@ -4,11 +4,20 @@ ## EmbeddableStateTransfer.clearEditorState() method +Clears the [editor state](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) from the sessionStorage for the provided app id + Signature: ```typescript -clearEditorState(): void; +clearEditorState(appId: string): void; ``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appId | string | The app to fetch incomingEditorState for | + Returns: `void` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md index 1434de2c9870e0..cd261bff5905b1 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md @@ -4,18 +4,19 @@ ## EmbeddableStateTransfer.getIncomingEditorState() method -Fetches an [originating app](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) argument from the sessionStorage +Fetches an [editor state](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) from the sessionStorage for the provided app id Signature: ```typescript -getIncomingEditorState(removeAfterFetch?: boolean): EmbeddableEditorState | undefined; +getIncomingEditorState(appId: string, removeAfterFetch?: boolean): EmbeddableEditorState | undefined; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | +| appId | string | The app to fetch incomingEditorState for | | removeAfterFetch | boolean | Whether to remove the package state after fetch to prevent duplicates. | Returns: diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md index 9ead71f0bb22c2..47873c8e91e413 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md @@ -4,18 +4,19 @@ ## EmbeddableStateTransfer.getIncomingEmbeddablePackage() method -Fetches an [embeddable package](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) argument from the sessionStorage +Fetches an [embeddable package](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) from the sessionStorage for the given AppId Signature: ```typescript -getIncomingEmbeddablePackage(removeAfterFetch?: boolean): EmbeddablePackageState | undefined; +getIncomingEmbeddablePackage(appId: string, removeAfterFetch?: boolean): EmbeddablePackageState | undefined; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | +| appId | string | The app to fetch EmbeddablePackageState for | | removeAfterFetch | boolean | Whether to remove the package state after fetch to prevent duplicates. | Returns: diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.md index 76b6708b93bd12..13c6c8c0325f1e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.md @@ -29,9 +29,9 @@ export declare class EmbeddableStateTransfer | Method | Modifiers | Description | | --- | --- | --- | -| [clearEditorState()](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md) | | | -| [getIncomingEditorState(removeAfterFetch)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md) | | Fetches an [originating app](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) argument from the sessionStorage | -| [getIncomingEmbeddablePackage(removeAfterFetch)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md) | | Fetches an [embeddable package](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) argument from the sessionStorage | +| [clearEditorState(appId)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md) | | Clears the [editor state](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) from the sessionStorage for the provided app id | +| [getIncomingEditorState(appId, removeAfterFetch)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingeditorstate.md) | | Fetches an [editor state](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) from the sessionStorage for the provided app id | +| [getIncomingEmbeddablePackage(appId, removeAfterFetch)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.getincomingembeddablepackage.md) | | Fetches an [embeddable package](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) from the sessionStorage for the given AppId | | [navigateToEditor(appId, options)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.navigatetoeditor.md) | | A wrapper around the method which navigates to the specified appId with [embeddable editor state](./kibana-plugin-plugins-embeddable-public.embeddableeditorstate.md) | | [navigateToWithEmbeddablePackage(appId, options)](./kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.navigatetowithembeddablepackage.md) | | A wrapper around the method which navigates to the specified appId with [embeddable package state](./kibana-plugin-plugins-embeddable-public.embeddablepackagestate.md) | diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts index b27322b6bec534..d12fea07bdd418 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -21,7 +21,7 @@ import { import { DashboardStateManager } from '../dashboard_state_manager'; import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashboard_app_functions'; -import { DashboardContainer, DashboardContainerInput } from '../..'; +import { DashboardConstants, DashboardContainer, DashboardContainerInput } from '../..'; import { DashboardAppServices } from '../types'; import { DASHBOARD_CONTAINER_TYPE } from '..'; @@ -68,7 +68,9 @@ export const useDashboardContainer = ( searchSession.restore(searchSessionIdFromURL); } - const incomingEmbeddable = embeddable.getStateTransfer().getIncomingEmbeddablePackage(true); + const incomingEmbeddable = embeddable + .getStateTransfer() + .getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, true); let canceled = false; let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined; diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index 763186fc17c0cd..a8ecb384f782b4 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -42,6 +42,10 @@ describe('embeddable state transfer', () => { const destinationApp = 'superUltraVisualize'; const originatingApp = 'superUltraTestDashboard'; + const testAppId = 'testApp'; + + const buildKey = (appId: string, key: string) => `${appId}-${key}`; + beforeEach(() => { currentAppId$ = new Subject(); currentAppId$.next(originatingApp); @@ -82,7 +86,9 @@ describe('embeddable state transfer', () => { it('can send an outgoing editor state', async () => { await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp } }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_EDITOR_STATE_KEY]: { originatingApp: 'superUltraTestDashboard' }, + [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'superUltraTestDashboard', + }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { path: undefined, @@ -98,7 +104,9 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [EMBEDDABLE_EDITOR_STATE_KEY]: { originatingApp: 'superUltraTestDashboard' }, + [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'superUltraTestDashboard', + }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { path: undefined, @@ -117,7 +125,10 @@ describe('embeddable state transfer', () => { state: { type: 'coolestType', input: { savedObjectId: '150' } }, }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_PACKAGE_STATE_KEY]: { type: 'coolestType', input: { savedObjectId: '150' } }, + [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { path: undefined, @@ -133,7 +144,10 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [EMBEDDABLE_PACKAGE_STATE_KEY]: { type: 'coolestType', input: { savedObjectId: '150' } }, + [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { path: undefined, @@ -151,42 +165,92 @@ describe('embeddable state transfer', () => { it('can fetch an incoming editor state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_EDITOR_STATE_KEY]: { originatingApp: 'superUltraTestDashboard' }, + [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'superUltraTestDashboard', + }, + }); + const fetchedState = stateTransfer.getIncomingEditorState(testAppId); + expect(fetchedState).toEqual({ originatingApp: 'superUltraTestDashboard' }); + }); + + it('can fetch an incoming editor state and ignore state for other apps', async () => { + store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { + [buildKey('otherApp1', EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'whoops not me', + }, + [buildKey('otherApp2', EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'otherTestDashboard', + }, + [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'superUltraTestDashboard', + }, }); - const fetchedState = stateTransfer.getIncomingEditorState(); + const fetchedState = stateTransfer.getIncomingEditorState(testAppId); expect(fetchedState).toEqual({ originatingApp: 'superUltraTestDashboard' }); + + const fetchedState2 = stateTransfer.getIncomingEditorState('otherApp2'); + expect(fetchedState2).toEqual({ originatingApp: 'otherTestDashboard' }); }); it('incoming editor state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_EDITOR_STATE_KEY]: { helloSportsKibana: 'superUltraTestDashboard' }, + [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { + helloSportsKibana: 'superUltraTestDashboard', + }, }); - const fetchedState = stateTransfer.getIncomingEditorState(); + const fetchedState = stateTransfer.getIncomingEditorState(testAppId); expect(fetchedState).toBeUndefined(); }); it('can fetch an incoming embeddable package state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_PACKAGE_STATE_KEY]: { type: 'skisEmbeddable', input: { savedObjectId: '123' } }, + [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, }); - const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); + const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); expect(fetchedState).toEqual({ type: 'skisEmbeddable', input: { savedObjectId: '123' } }); }); + it('can fetch an incoming embeddable package state and ignore state for other apps', async () => { + store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { + [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, + [buildKey('testApp2', EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'crossCountryEmbeddable', + input: { savedObjectId: '456' }, + }, + }); + const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); + expect(fetchedState).toEqual({ type: 'skisEmbeddable', input: { savedObjectId: '123' } }); + + const fetchedState2 = stateTransfer.getIncomingEmbeddablePackage('testApp2'); + expect(fetchedState2).toEqual({ + type: 'crossCountryEmbeddable', + input: { savedObjectId: '456' }, + }); + }); + it('embeddable package state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_PACKAGE_STATE_KEY]: { kibanaIsFor: 'sports' }, + [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { kibanaIsFor: 'sports' }, }); - const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); + const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); expect(fetchedState).toBeUndefined(); }); it('removes embeddable package key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_PACKAGE_STATE_KEY]: { type: 'coolestType', input: { savedObjectId: '150' } }, + [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, iSHouldStillbeHere: 'doing the sports thing', }); - stateTransfer.getIncomingEmbeddablePackage(true); + stateTransfer.getIncomingEmbeddablePackage(testAppId, true); expect(store.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)).toEqual({ iSHouldStillbeHere: 'doing the sports thing', }); @@ -194,10 +258,12 @@ describe('embeddable state transfer', () => { it('removes editor state key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [EMBEDDABLE_EDITOR_STATE_KEY]: { originatingApp: 'superCoolFootballDashboard' }, + [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { + originatingApp: 'superCoolFootballDashboard', + }, iSHouldStillbeHere: 'doing the sports thing', }); - stateTransfer.getIncomingEditorState(true); + stateTransfer.getIncomingEditorState(testAppId, true); expect(store.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)).toEqual({ iSHouldStillbeHere: 'doing the sports thing', }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts index d3b1c1c76aadfe..8664a5aae7345f 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts @@ -50,13 +50,18 @@ export class EmbeddableStateTransfer { public getAppNameFromId = (appId: string): string | undefined => this.appList?.get(appId)?.title; /** - * Fetches an {@link EmbeddableEditorState | originating app} argument from the sessionStorage + * Fetches an {@link EmbeddableEditorState | editor state} from the sessionStorage for the provided app id * + * @param appId - The app to fetch incomingEditorState for * @param removeAfterFetch - Whether to remove the package state after fetch to prevent duplicates. */ - public getIncomingEditorState(removeAfterFetch?: boolean): EmbeddableEditorState | undefined { + public getIncomingEditorState( + appId: string, + removeAfterFetch?: boolean + ): EmbeddableEditorState | undefined { return this.getIncomingState( isEmbeddableEditorState, + appId, EMBEDDABLE_EDITOR_STATE_KEY, { keysToRemoveAfterFetch: removeAfterFetch ? [EMBEDDABLE_EDITOR_STATE_KEY] : undefined, @@ -64,24 +69,33 @@ export class EmbeddableStateTransfer { ); } - public clearEditorState() { + /** + * Clears the {@link EmbeddableEditorState | editor state} from the sessionStorage for the provided app id + * + * @param appId - The app to fetch incomingEditorState for + * @param removeAfterFetch - Whether to remove the package state after fetch to prevent duplicates. + */ + public clearEditorState(appId: string) { const currentState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY); if (currentState) { - delete currentState[EMBEDDABLE_EDITOR_STATE_KEY]; + delete currentState[this.buildKey(appId, EMBEDDABLE_EDITOR_STATE_KEY)]; this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, currentState); } } /** - * Fetches an {@link EmbeddablePackageState | embeddable package} argument from the sessionStorage + * Fetches an {@link EmbeddablePackageState | embeddable package} from the sessionStorage for the given AppId * + * @param appId - The app to fetch EmbeddablePackageState for * @param removeAfterFetch - Whether to remove the package state after fetch to prevent duplicates. */ public getIncomingEmbeddablePackage( + appId: string, removeAfterFetch?: boolean ): EmbeddablePackageState | undefined { return this.getIncomingState( isEmbeddablePackageState, + appId, EMBEDDABLE_PACKAGE_STATE_KEY, { keysToRemoveAfterFetch: removeAfterFetch ? [EMBEDDABLE_PACKAGE_STATE_KEY] : undefined, @@ -122,20 +136,27 @@ export class EmbeddableStateTransfer { }); } + private buildKey(appId: string, key: string) { + return `${appId}-${key}`; + } + private getIncomingState( guard: (state: unknown) => state is IncomingStateType, + appId: string, key: string, options?: { keysToRemoveAfterFetch?: string[]; } ): IncomingStateType | undefined { - const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[key]; + const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[ + this.buildKey(appId, key) + ]; const castState = !guard || guard(incomingState) ? (cloneDeep(incomingState) as IncomingStateType) : undefined; if (castState && options?.keysToRemoveAfterFetch) { const stateReplace = { ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY) }; options.keysToRemoveAfterFetch.forEach((keyToRemove: string) => { - delete stateReplace[keyToRemove]; + delete stateReplace[this.buildKey(appId, keyToRemove)]; }); this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateReplace); } @@ -150,9 +171,9 @@ export class EmbeddableStateTransfer { const stateObject = options?.appendToExistingState ? { ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY), - [key]: options.state, + [this.buildKey(appId, key)]: options.state, } - : { [key]: options?.state }; + : { [this.buildKey(appId, key)]: options?.state }; this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateObject); await this.navigateToApp(appId, { path: options?.path }); } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 2f9b43121b45a4..3e7014d54958de 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -590,11 +590,10 @@ export class EmbeddableStateTransfer { // Warning: (ae-forgotten-export) The symbol "ApplicationStart" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "PublicAppInfo" needs to be exported by the entry point index.d.ts constructor(navigateToApp: ApplicationStart['navigateToApp'], currentAppId$: ApplicationStart['currentAppId$'], appList?: ReadonlyMap | undefined, customStorage?: Storage); - // (undocumented) - clearEditorState(): void; + clearEditorState(appId: string): void; getAppNameFromId: (appId: string) => string | undefined; - getIncomingEditorState(removeAfterFetch?: boolean): EmbeddableEditorState | undefined; - getIncomingEmbeddablePackage(removeAfterFetch?: boolean): EmbeddablePackageState | undefined; + getIncomingEditorState(appId: string, removeAfterFetch?: boolean): EmbeddableEditorState | undefined; + getIncomingEmbeddablePackage(appId: string, removeAfterFetch?: boolean): EmbeddablePackageState | undefined; // (undocumented) isTransferInProgress: boolean; // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ApplicationStart" diff --git a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx index 6ca6efaa897970..fa0e0bd5f48f08 100644 --- a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx @@ -34,7 +34,7 @@ export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { useEffect(() => { const { originatingApp: value, embeddableId: embeddableIdValue, valueInput: valueInputValue } = - services.stateTransferService.getIncomingEditorState() || {}; + services.stateTransferService.getIncomingEditorState(VisualizeConstants.APP_ID) || {}; setOriginatingApp(value); setValueInput(valueInputValue); setEmbeddableId(embeddableIdValue); diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx index 7465e7eaa90441..c6333e978183ff 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -22,6 +22,7 @@ import { import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; import { VisualizeAppProps } from '../app'; +import { VisualizeConstants } from '../..'; export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { const { id: visualizationIdFromUrl } = useParams<{ id: string }>(); @@ -54,7 +55,8 @@ export const VisualizeEditor = ({ onAppLeave }: VisualizeAppProps) => { useLinkedSearchUpdates(services, eventEmitter, appState, savedVisInstance); useEffect(() => { - const { originatingApp: value } = services.stateTransferService.getIncomingEditorState() || {}; + const { originatingApp: value } = + services.stateTransferService.getIncomingEditorState(VisualizeConstants.APP_ID) || {}; setOriginatingApp(value); }, [services]); diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index c772554344cb26..bc766d63db5a78 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -65,7 +65,7 @@ export const VisualizeListing = () => { useMount(() => { // Reset editor state if the visualize listing page is loaded. - stateTransferService.clearEditorState(); + stateTransferService.clearEditorState(VisualizeConstants.APP_ID); chrome.setBreadcrumbs([ { text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 9ea42e8b565597..e8c3289d4ce411 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -142,7 +142,7 @@ export const getTopNavConfig = ( if (setOriginatingApp && originatingApp && newlyCreated) { setOriginatingApp(undefined); // remove editor state so the connection is still broken after reload - stateTransfer.clearEditorState(); + stateTransfer.clearEditorState(VisualizeConstants.APP_ID); } chrome.docTitle.change(savedVis.lastSavedTitle); chrome.setBreadcrumbs(getEditBreadcrumbs({}, savedVis.lastSavedTitle)); diff --git a/src/plugins/visualize/public/application/visualize_constants.ts b/src/plugins/visualize/public/application/visualize_constants.ts index 7dbf5be77b74d7..6e901882a9365d 100644 --- a/src/plugins/visualize/public/application/visualize_constants.ts +++ b/src/plugins/visualize/public/application/visualize_constants.ts @@ -16,4 +16,5 @@ export const VisualizeConstants = { CREATE_PATH: '/create', EDIT_PATH: '/edit', EDIT_BY_VALUE_PATH: '/edit_by_value', + APP_ID: 'visualize', }; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 3d82e6c60a1b6e..4eb2d6fd2a731c 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -132,7 +132,7 @@ export class VisualizePlugin setUISettings(core.uiSettings); core.application.register({ - id: 'visualize', + id: VisualizeConstants.APP_ID, title: 'Visualize', order: 8000, euiIconType: 'logoKibana', @@ -147,7 +147,9 @@ export class VisualizePlugin // allows the urlTracker to only save URLs that are not linked to an originatingApp this.isLinkedToOriginatingApp = () => { return Boolean( - pluginsStart.embeddable.getStateTransfer().getIncomingEditorState()?.originatingApp + pluginsStart.embeddable + .getStateTransfer() + .getIncomingEditorState(VisualizeConstants.APP_ID)?.originatingApp ); }; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 202b80d3d84060..c3e556b1678890 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -6,6 +6,7 @@ */ export const PLUGIN_ID = 'lens'; +export const APP_ID = 'lens'; export const LENS_EMBEDDABLE_TYPE = 'lens'; export const DOC_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 7e95479887dbd1..0d72a366fa4119 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -38,7 +38,7 @@ import { SavedQuery, syncQueryStateWithUrl, } from '../../../../../src/plugins/data/public'; -import { LENS_EMBEDDABLE_TYPE, getFullPath } from '../../common'; +import { LENS_EMBEDDABLE_TYPE, getFullPath, APP_ID } from '../../common'; import { LensAppProps, LensAppServices, LensAppState } from './types'; import { getLensTopNavConfig } from './lens_top_nav'; import { Document } from '../persistence'; @@ -498,7 +498,7 @@ export function App({ isLinkedToOriginatingApp: false, })); // remove editor state so the connection is still broken after reload - stateTransfer.clearEditorState(); + stateTransfer.clearEditorState(APP_ID); redirectTo(newInput.savedObjectId); return; diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 1ff31e5d4bf6bf..5869151485a526 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -23,7 +23,7 @@ import { App } from './app'; import { EditorFrameStart } from '../types'; import { addHelpMenuToAppChrome } from '../help_menu_util'; import { LensPluginStartDependencies } from '../plugin'; -import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE } from '../../common'; +import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common'; import { LensEmbeddableInput, LensByReferenceInput, @@ -57,7 +57,7 @@ export async function mountApp( const storage = new Storage(localStorage); const stateTransfer = embeddable?.getStateTransfer(); const historyLocationState = params.history.location.state as HistoryLocationState; - const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(); + const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(APP_ID); const lensServices: LensAppServices = { data, diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 05da76d9fd2076..c667ddea06b331 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -40,7 +40,7 @@ import { ACTION_VISUALIZE_FIELD, VISUALIZE_FIELD_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; -import { getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; +import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; @@ -182,7 +182,7 @@ export class LensPlugin { }; core.application.register({ - id: 'lens', + id: APP_ID, title: NOT_INTERNATIONALIZED_PRODUCT_NAME, navLinkStatus: AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index ccd30126b67bd5..4d1dff9303b0c5 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -26,6 +26,7 @@ import { } from '../../../../src/plugins/kibana_utils/public'; import { ListPage, MapPage } from './routes'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; +import { APP_ID } from '../common/constants'; export let goToSpecifiedPath: (path: string) => void; export let kbnUrlStateStorage: IKbnUrlStateStorage; @@ -80,7 +81,7 @@ export async function renderApp({ function renderMapApp(routeProps: RouteComponentProps<{ savedMapId?: string }>) { const { embeddableId, originatingApp, valueInput } = - stateTransfer.getIncomingEditorState() || {}; + stateTransfer.getIncomingEditorState(APP_ID) || {}; let mapEmbeddableInput; if (routeProps.match.params.savedMapId) { diff --git a/x-pack/plugins/maps/public/routes/list_page/load_list_and_render.tsx b/x-pack/plugins/maps/public/routes/list_page/load_list_and_render.tsx index 66b65eb8d0a9d7..feafb34f6a7152 100644 --- a/x-pack/plugins/maps/public/routes/list_page/load_list_and_render.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/load_list_and_render.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { Redirect } from 'react-router-dom'; import { getSavedObjectsClient, getToasts } from '../../kibana_services'; import { MapsListView } from './maps_list_view'; -import { MAP_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { APP_ID, MAP_SAVED_OBJECT_TYPE } from '../../../common/constants'; import { EmbeddableStateTransfer } from '../../../../../../src/plugins/embeddable/public'; export class LoadListAndRender extends React.Component<{ stateTransfer: EmbeddableStateTransfer }> { @@ -22,7 +22,7 @@ export class LoadListAndRender extends React.Component<{ stateTransfer: Embeddab componentDidMount() { this._isMounted = true; - this.props.stateTransfer.clearEditorState(); + this.props.stateTransfer.clearEditorState(APP_ID); this._loadMapsList(); } diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts index d38ff8b3e4da61..b6ee5274f690d8 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { MapSavedObjectAttributes } from '../../../../common/map_saved_object_type'; -import { MAP_PATH, MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants'; +import { APP_ID, MAP_PATH, MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants'; import { createMapStore, MapStore, MapStoreState } from '../../../reducers/store'; import { getTimeFilters, @@ -364,7 +364,7 @@ export class SavedMap { this._originatingApp = undefined; // remove editor state so the connection is still broken after reload - this._getStateTransfer().clearEditorState(); + this._getStateTransfer().clearEditorState(APP_ID); getToasts().addSuccess({ title: i18n.translate('xpack.maps.topNav.saveSuccessMessage', { From cde3cbafe4f4506c7444b4c40a0a33027a4740e9 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 8 Feb 2021 15:53:01 -0500 Subject: [PATCH 14/18] fix summary alert details (#90657) --- .../factory/events/details/helpers.ts | 12 +- .../security_solution/timeline_details.ts | 158 +++++++++--------- 2 files changed, 82 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts index 4a6a1d61a92214..779454e9474ee7 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts @@ -51,16 +51,8 @@ export const getDataFromSourceHits = ( { category: fieldCategory, field, - values: Array.isArray(item) - ? item.map((value) => { - if (isObject(value)) { - return JSON.stringify(value); - } - - return value; - }) - : [item], - originalValue: item, + values: toStringArray(item), + originalValue: toStringArray(item), } as TimelineEventsDetailsItem, ]; } else if (isObject(item)) { diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index 2705406009062d..39b343a3619457 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -19,97 +19,97 @@ const EXPECTED_DATA = [ category: 'base', field: '@timestamp', values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + originalValue: ['2019-02-10T02:39:44.107Z'], }, { category: '@version', field: '@version', values: ['1'], - originalValue: '1', + originalValue: ['1'], }, { category: 'agent', field: 'agent.ephemeral_id', values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], - originalValue: '909cd6a1-527d-41a5-9585-a7fb5386f851', + originalValue: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], }, { category: 'agent', field: 'agent.hostname', values: ['raspberrypi'], - originalValue: 'raspberrypi', + originalValue: ['raspberrypi'], }, { category: 'agent', field: 'agent.id', values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], - originalValue: '4d3ea604-27e5-4ec7-ab64-44f82285d776', + originalValue: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], }, { category: 'agent', field: 'agent.type', values: ['filebeat'], - originalValue: 'filebeat', + originalValue: ['filebeat'], }, { category: 'agent', field: 'agent.version', values: ['7.0.0'], - originalValue: '7.0.0', + originalValue: ['7.0.0'], }, { category: 'destination', field: 'destination.domain', values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { category: 'destination', field: 'destination.ip', values: ['10.100.7.196'], - originalValue: '10.100.7.196', + originalValue: ['10.100.7.196'], }, { category: 'destination', field: 'destination.port', - values: [40684], - originalValue: 40684, + values: ['40684'], + originalValue: ['40684'], }, { category: 'ecs', field: 'ecs.version', values: ['1.0.0-beta2'], - originalValue: '1.0.0-beta2', + originalValue: ['1.0.0-beta2'], }, { category: 'event', field: 'event.dataset', values: ['suricata.eve'], - originalValue: 'suricata.eve', + originalValue: ['suricata.eve'], }, { category: 'event', field: 'event.end', values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + originalValue: ['2019-02-10T02:39:44.107Z'], }, { category: 'event', field: 'event.kind', values: ['event'], - originalValue: 'event', + originalValue: ['event'], }, { category: 'event', field: 'event.module', values: ['suricata'], - originalValue: 'suricata', + originalValue: ['suricata'], }, { category: 'event', field: 'event.type', values: ['fileinfo'], - originalValue: 'fileinfo', + originalValue: ['fileinfo'], }, { category: 'file', @@ -117,260 +117,261 @@ const EXPECTED_DATA = [ values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, { category: 'file', field: 'file.size', - values: [48277], - originalValue: 48277, + values: ['48277'], + originalValue: ['48277'], }, { category: 'fileset', field: 'fileset.name', values: ['eve'], - originalValue: 'eve', + originalValue: ['eve'], }, { category: 'flow', field: 'flow.locality', values: ['public'], - originalValue: 'public', + originalValue: ['public'], }, { category: 'host', field: 'host.architecture', values: ['armv7l'], - originalValue: 'armv7l', + originalValue: ['armv7l'], }, { category: 'host', field: 'host.hostname', values: ['raspberrypi'], - originalValue: 'raspberrypi', + originalValue: ['raspberrypi'], }, { category: 'host', field: 'host.id', values: ['b19a781f683541a7a25ee345133aa399'], - originalValue: 'b19a781f683541a7a25ee345133aa399', + originalValue: ['b19a781f683541a7a25ee345133aa399'], }, { category: 'host', field: 'host.name', values: ['raspberrypi'], - originalValue: 'raspberrypi', + originalValue: ['raspberrypi'], }, { category: 'host', field: 'host.os.codename', values: ['stretch'], - originalValue: 'stretch', + originalValue: ['stretch'], }, { category: 'host', field: 'host.os.family', values: [''], - originalValue: '', + originalValue: [''], }, { category: 'host', field: 'host.os.kernel', values: ['4.14.50-v7+'], - originalValue: '4.14.50-v7+', + originalValue: ['4.14.50-v7+'], }, { category: 'host', field: 'host.os.name', values: ['Raspbian GNU/Linux'], - originalValue: 'Raspbian GNU/Linux', + originalValue: ['Raspbian GNU/Linux'], }, { category: 'host', field: 'host.os.platform', values: ['raspbian'], - originalValue: 'raspbian', + originalValue: ['raspbian'], }, { category: 'host', field: 'host.os.version', values: ['9 (stretch)'], - originalValue: '9 (stretch)', + originalValue: ['9 (stretch)'], }, { category: 'http', field: 'http.request.method', values: ['get'], - originalValue: 'get', + originalValue: ['get'], }, { category: 'http', field: 'http.response.body.bytes', - values: [48277], - originalValue: 48277, + values: ['48277'], + originalValue: ['48277'], }, { category: 'http', field: 'http.response.status_code', - values: [206], - originalValue: 206, + values: ['206'], + originalValue: ['206'], }, { category: 'input', field: 'input.type', values: ['log'], - originalValue: 'log', + originalValue: ['log'], }, { category: 'base', field: 'labels.pipeline', values: ['filebeat-7.0.0-suricata-eve-pipeline'], - originalValue: 'filebeat-7.0.0-suricata-eve-pipeline', + originalValue: ['filebeat-7.0.0-suricata-eve-pipeline'], }, { category: 'log', field: 'log.file.path', values: ['/var/log/suricata/eve.json'], - originalValue: '/var/log/suricata/eve.json', + originalValue: ['/var/log/suricata/eve.json'], }, { category: 'log', field: 'log.offset', - values: [1856288115], - originalValue: 1856288115, + values: ['1856288115'], + originalValue: ['1856288115'], }, { category: 'network', field: 'network.name', values: ['iot'], - originalValue: 'iot', + originalValue: ['iot'], }, { category: 'network', field: 'network.protocol', values: ['http'], - originalValue: 'http', + originalValue: ['http'], }, { category: 'network', field: 'network.transport', values: ['tcp'], - originalValue: 'tcp', + originalValue: ['tcp'], }, { category: 'service', field: 'service.type', values: ['suricata'], - originalValue: 'suricata', + originalValue: ['suricata'], }, { category: 'source', field: 'source.as.num', - values: [16509], - originalValue: 16509, + values: ['16509'], + originalValue: ['16509'], }, { category: 'source', field: 'source.as.org', values: ['Amazon.com, Inc.'], - originalValue: 'Amazon.com, Inc.', + originalValue: ['Amazon.com, Inc.'], }, { category: 'source', field: 'source.domain', values: ['server-54-239-219-210.jfk51.r.cloudfront.net'], - originalValue: 'server-54-239-219-210.jfk51.r.cloudfront.net', + originalValue: ['server-54-239-219-210.jfk51.r.cloudfront.net'], }, { category: 'source', field: 'source.geo.city_name', values: ['Seattle'], - originalValue: 'Seattle', + originalValue: ['Seattle'], }, { category: 'source', field: 'source.geo.continent_name', values: ['North America'], - originalValue: 'North America', + originalValue: ['North America'], }, { category: 'source', field: 'source.geo.country_iso_code', values: ['US'], - originalValue: 'US', + originalValue: ['US'], }, { category: 'source', field: 'source.geo.location.lat', - values: [47.6103], - originalValue: 47.6103, + values: ['47.6103'], + originalValue: ['47.6103'], }, { category: 'source', field: 'source.geo.location.lon', - values: [-122.3341], - originalValue: -122.3341, + values: ['-122.3341'], + originalValue: ['-122.3341'], }, { category: 'source', field: 'source.geo.region_iso_code', values: ['US-WA'], - originalValue: 'US-WA', + originalValue: ['US-WA'], }, { category: 'source', field: 'source.geo.region_name', values: ['Washington'], - originalValue: 'Washington', + originalValue: ['Washington'], }, { category: 'source', field: 'source.ip', values: ['54.239.219.210'], - originalValue: '54.239.219.210', + originalValue: ['54.239.219.210'], }, { category: 'source', field: 'source.port', - values: [80], - originalValue: 80, + values: ['80'], + originalValue: ['80'], }, { category: 'suricata', field: 'suricata.eve.fileinfo.state', values: ['CLOSED'], - originalValue: 'CLOSED', + originalValue: ['CLOSED'], }, { category: 'suricata', field: 'suricata.eve.fileinfo.tx_id', - values: [301], - originalValue: 301, + values: ['301'], + originalValue: ['301'], }, { category: 'suricata', field: 'suricata.eve.flow_id', - values: [196625917175466], - originalValue: 196625917175466, + values: ['196625917175466'], + originalValue: ['196625917175466'], }, { category: 'suricata', field: 'suricata.eve.http.http_content_type', values: ['video/mp4'], - originalValue: 'video/mp4', + originalValue: ['video/mp4'], }, { category: 'suricata', field: 'suricata.eve.http.protocol', values: ['HTTP/1.1'], - originalValue: 'HTTP/1.1', + originalValue: ['HTTP/1.1'], }, { category: 'suricata', field: 'suricata.eve.in_iface', values: ['eth0'], - originalValue: 'eth0', + originalValue: ['eth0'], }, { category: 'base', @@ -382,7 +383,7 @@ const EXPECTED_DATA = [ category: 'url', field: 'url.domain', values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { category: 'url', @@ -390,8 +391,9 @@ const EXPECTED_DATA = [ values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, { category: 'url', @@ -399,26 +401,27 @@ const EXPECTED_DATA = [ values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, { category: '_index', field: '_index', values: ['filebeat-7.0.0-iot-2019.06'], - originalValue: 'filebeat-7.0.0-iot-2019.06', + originalValue: ['filebeat-7.0.0-iot-2019.06'], }, { category: '_id', field: '_id', values: ['QRhG1WgBqd-n62SwZYDT'], - originalValue: 'QRhG1WgBqd-n62SwZYDT', + originalValue: ['QRhG1WgBqd-n62SwZYDT'], }, { category: '_score', field: '_score', - values: [1], - originalValue: 1, + values: ['1'], + originalValue: ['1'], }, ]; @@ -452,7 +455,6 @@ export default function ({ getService }: FtrProviderContext) { eventId: ID, }) .expect(200); - expect(sortBy(detailsData, 'name')).to.eql(sortBy(EXPECTED_DATA, 'name')); }); From 180f309fab1dec168a0a7ae81ac497f46966b15d Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Mon, 8 Feb 2021 16:05:26 -0500 Subject: [PATCH 15/18] Update security solution codeowners (#89038) * move the code coverage owner line for security solution next to the other lines about security solution * replace @elastic/siem with @elastic/security-solution and remove the duplicate code coverage owner for security_solution * remove elastic/endpoint-app-team and cleanup directories that no longer exist, and reorder lines * added case_api_integration code owners Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2917cc52a6c6db..b6c0c6afdee0bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -244,7 +244,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security -#CC# /x-pack/plugins/security_solution/ @elastic/kibana-security #CC# /x-pack/plugins/security/ @elastic/kibana-security # Kibana Alerting Services @@ -312,25 +311,22 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib #CC# /x-pack/plugins/console_extensions/ @elastic/es-ui #CC# /x-pack/plugins/cross_cluster_replication/ @elastic/es-ui -# Endpoint -/x-pack/plugins/endpoint/ @elastic/endpoint-app-team @elastic/siem -/x-pack/test/endpoint_api_integration_no_ingest/ @elastic/endpoint-app-team @elastic/siem -/x-pack/test/security_solution_endpoint/ @elastic/endpoint-app-team @elastic/siem -/x-pack/test/functional/es_archives/endpoint/ @elastic/endpoint-app-team @elastic/siem -/x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/endpoint-app-team @elastic/siem -/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/endpoint-app-team @elastic/siem -#CC# /x-pack/legacy/plugins/siem/ @elastic/siem -#CC# /x-pack/plugins/siem/ @elastic/siem -#CC# /x-pack/plugins/security_solution/ @elastic/siem - # Security Solution -/x-pack/plugins/security_solution/ @elastic/siem @elastic/endpoint-app-team -/x-pack/test/detection_engine_api_integration @elastic/siem @elastic/endpoint-app-team -/x-pack/test/lists_api_integration @elastic/siem @elastic/endpoint-app-team -/x-pack/test/api_integration/apis/security_solution @elastic/siem @elastic/endpoint-app-team -/x-pack/plugins/case @elastic/siem @elastic/endpoint-app-team -/x-pack/plugins/lists @elastic/siem @elastic/endpoint-app-team -#CC# /x-pack/plugins/security_solution/ @elastic/siem +/x-pack/test/endpoint_api_integration_no_ingest/ @elastic/security-solution +/x-pack/test/security_solution_endpoint/ @elastic/security-solution +/x-pack/test/functional/es_archives/endpoint/ @elastic/security-solution +/x-pack/test/plugin_functional/plugins/resolver_test/ @elastic/security-solution +/x-pack/test/plugin_functional/test_suites/resolver/ @elastic/security-solution +/x-pack/plugins/security_solution/ @elastic/security-solution +/x-pack/test/detection_engine_api_integration @elastic/security-solution +/x-pack/test/lists_api_integration @elastic/security-solution +/x-pack/test/api_integration/apis/security_solution @elastic/security-solution +#CC# /x-pack/plugins/security_solution/ @elastic/security-solution + +# Security Solution sub teams +/x-pack/plugins/case @elastic/security-threat-hunting +/x-pack/test/case_api_integration @elastic/security-threat-hunting +/x-pack/plugins/lists @elastic/security-detections-response # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics @@ -362,3 +358,4 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Reporting #CC# /x-pack/plugins/reporting/ @elastic/kibana-reporting-services + From 3fa76956ac3e963cd6d89f2f390b47e47d3f65b0 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 8 Feb 2021 16:08:23 -0500 Subject: [PATCH 16/18] [CI] Automated backports via GitHub Actions - initial MVP (#90669) --- .github/CODEOWNERS | 1 + .github/workflows/backport.yml | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .github/workflows/backport.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b6c0c6afdee0bf..ec07a6a03d2c8f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -149,6 +149,7 @@ /src/cli/keystore/ @elastic/kibana-operations /src/legacy/server/warnings/ @elastic/kibana-operations /.ci/es-snapshots/ @elastic/kibana-operations +/.github/workflows/ @elastic/kibana-operations /vars/ @elastic/kibana-operations /.bazelignore @elastic/kibana-operations /.bazeliskversion @elastic/kibana-operations diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000000000..f64b9e95fbaabc --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,46 @@ +on: + pull_request_target: + branches: + - master + types: + - labeled + - closed + +jobs: + backport: + name: Backport PR + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'auto-backport') + runs-on: ubuntu-latest + + steps: + - name: 'Get backport config' + run: | + curl 'https://raw.githubusercontent.com/elastic/kibana/master/.backportrc.json' > .backportrc.json + + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14.x + + - name: Install backport CLI + run: npm install -g backport@5.6.4 + + - name: Backport PR + run: | + git config --global user.name "kibanamachine" + git config --global user.email "42973632+kibanamachine@users.noreply.github.com" + backport --fork true --username kibanamachine --accessToken "${{ secrets.KIBANAMACHINE_TOKEN }}" --ci --pr "$PR_NUMBER" --labels backport --assignee "$PR_OWNER" | tee 'output.log' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_OWNER: ${{ github.event.pull_request.user.login }} + + - name: Report backport status + run: | + COMMENT="Backport result + \`\`\` + $(cat output.log) + \`\`\`" + + GITHUB_TOKEN="${{ secrets.KIBANAMACHINE_TOKEN }}" gh api -X POST repos/elastic/kibana/issues/$PR_NUMBER/comments -F body="$COMMENT" + env: + PR_NUMBER: ${{ github.event.pull_request.number }} From 7a2b7550c962c5b6084f8f84e2dc9a2b5f3db0a2 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Mon, 8 Feb 2021 15:10:23 -0600 Subject: [PATCH 17/18] [DOCS] Fixes Dashboard formatting (#90485) * [DOCS] Fixes Dashboard formatting * Fixes the semi-structured search example * Update docs/user/dashboard/dashboard.asciidoc Co-authored-by: Wylie Conlon Co-authored-by: Wylie Conlon --- docs/user/dashboard/dashboard.asciidoc | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/user/dashboard/dashboard.asciidoc b/docs/user/dashboard/dashboard.asciidoc index 8b3eddc008500d..3c86c37f1fd301 100644 --- a/docs/user/dashboard/dashboard.asciidoc +++ b/docs/user/dashboard/dashboard.asciidoc @@ -133,15 +133,17 @@ image::dashboard/images/dashboard-filters.png[Labeled interface with semi-struct Semi-structured search:: Combine free text search with field-based search using the <>. Type a search term to match across all fields, or begin typing a field name to - get prompted with field names and operators you can use to build a structured query. - + + get prompted with field names and operators you can use to build a structured query. For example, in the sample web logs data, this query displays data only for the US: - . Enter `g`, and then select *geo.source*. - . Select *equals some value* and *US*, and then click *Update*. + . Enter `g`, then select *geo.source*. + . Select *equals some value* and *US*, then click *Update*. . For a more complex search, try: - `geo.src : "US" and url.keyword : "https://www.elastic.co/downloads/beats/metricbeat"` +[source,text] +------------------- +geo.src : "US" and url.keyword : "https://www.elastic.co/downloads/beats/metricbeat" +------------------- Time filter:: Dashboards have a global time filter that restricts the data that displays, but individual panels can @@ -152,21 +154,18 @@ Time filter:: . Open the panel menu, then select *More > Customize time range*. . On the *Customize panel time range* window, specify the new time range, then click *Add to panel*. - + [role="screenshot"] image:images/time_range_per_panel.gif[Time range per dashboard panel] Additional filters with AND:: - You can add filters to a dashboard, or pin filters to multiple places in {kib}. To add filters, using a basic editor or an advanced JSON editor for the {es} {ref}/query-dsl.html[query DSL]. - + Add filters to a dashboard, or pin filters to multiple places in {kib}. To add filters, using a basic editor or an advanced JSON editor for the {es} {ref}/query-dsl.html[query DSL]. When you use more than one index pattern on a dashboard, the filter editor allows you to filter only one dashboard. - To dynamically add filters, click a series on a dashboard. For example, to filter the dashboard to display only ios data: - . Click *Add filter*. . Set *Field* to *machine.os*, *Operator* to *is*, and *Value* to *ios*. . *Save* the filter. - . To remove the filter, click *x* next to the filter. + . To remove the filter, click *x*. [float] [[clone-panels]] From 46feb7659279b98d07ae7c7a259cd666605ba6ae Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 8 Feb 2021 23:42:07 +0200 Subject: [PATCH 18/18] [Alerts] Jira: Disallow labels with spaces (#90548) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/actions/README.md | 2 +- .../builtin_action_types/jira/schema.ts | 10 ++++++- .../builtin_action_types/jira/jira.test.tsx | 19 ++++++++++++- .../builtin_action_types/jira/jira.tsx | 7 +++++ .../builtin_action_types/jira/jira_params.tsx | 8 ++++++ .../builtin_action_types/jira/translations.ts | 7 +++++ .../actions/builtin_action_types/jira.ts | 28 +++++++++++++++++++ 7 files changed, 78 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 1eb94af4dddf8c..1d50bc7e058070 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -657,7 +657,7 @@ The following table describes the properties of the `incident` object. | externalId | The id of the issue in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | | issueType | The id of the issue type in Jira. | string _(optional)_ | | priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | -| labels | An array of labels. | string[] _(optional)_ | +| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ | | parent | The parent issue id or key. Only for `Sub-task` issue types. | string _(optional)_ | #### `subActionParams (getIncident)` diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 552053bdd76516..a81dfaeef8175a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -40,7 +40,15 @@ export const ExecutorSubActionPushParamsSchema = schema.object({ externalId: schema.nullable(schema.string()), issueType: schema.nullable(schema.string()), priority: schema.nullable(schema.string()), - labels: schema.nullable(schema.arrayOf(schema.string())), + labels: schema.nullable( + schema.arrayOf( + schema.string({ + validate: (label) => + // Matches any space, tab or newline character. + label.match(/\s/g) ? `The label ${label} cannot contain spaces` : undefined, + }) + ) + ), parent: schema.nullable(schema.string()), }), comments: schema.nullable( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index 2d47740a581b80..ea1bcf82c314c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -96,7 +96,7 @@ describe('jira action params validation', () => { }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { 'subActionParams.incident.summary': [] }, + errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] }, }); }); @@ -108,6 +108,23 @@ describe('jira action params validation', () => { expect(actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': ['Summary is required.'], + 'subActionParams.incident.labels': [], + }, + }); + }); + + test('params validation fails when labels contain spaces', () => { + const actionParams = { + subActionParams: { + incident: { summary: 'some title', labels: ['label with spaces'] }, + comments: [], + }, + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.summary': [], + 'subActionParams.incident.labels': ['Labels cannot contain spaces.'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index 5cb8a76d09bee6..26b37278003c35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -72,6 +72,7 @@ export function getActionType(): ActionTypeModel => { const errors = { 'subActionParams.incident.summary': new Array(), + 'subActionParams.incident.labels': new Array(), }; const validationResult = { errors, @@ -83,6 +84,12 @@ export function getActionType(): ActionTypeModel label.match(/\s/g))) + errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES); + } return validationResult; }, actionParamsFields: lazy(() => import('./jira_params')), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 75930482797a29..cb2d637972cb82 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -184,6 +184,11 @@ const JiraParamsFields: React.FunctionComponent 0 && + incident.labels !== undefined; + return ( <> @@ -304,6 +309,8 @@ const JiraParamsFields: React.FunctionComponent
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 3c8bda7792f0a1..fe7ea61e681931 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -199,3 +199,10 @@ export const SEARCH_ISSUES_LOADING = i18n.translate( defaultMessage: 'Loading...', } ); + +export const LABELS_WHITE_SPACES = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.labelsSpacesErrorMessage', + { + defaultMessage: 'Labels cannot contain spaces.', + } +); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 6cc5e2eaefb94a..8bd0b8a790d402 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -375,6 +375,34 @@ export default function jiraTest({ getService }: FtrProviderContext) { }); }); }); + + it('should handle failing with a simulated success when labels containing a space', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockJira.params, + subActionParams: { + incident: { + ...mockJira.params.subActionParams.incident, + issueType: '10006', + labels: ['label with spaces'], + }, + comments: [], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.labels]: types that failed validation:\n - [subActionParams.incident.labels.0.0]: The label label with spaces cannot contain spaces\n - [subActionParams.incident.labels.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', + }); + }); + }); }); describe('Execution', () => {