diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 40e57e402f6774..086098cee67908 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -154,18 +154,16 @@ steps: - exit_status: '-1' limit: 3 - - command: .buildkite/scripts/steps/check_types_commits.sh - label: 'Check Types Commit Diff' - # TODO: Enable in #166813 after fixing types - # - command: .buildkite/scripts/steps/check_types.sh - # label: 'Check Types' - agents: - queue: n2-16-spot - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: '-1' - limit: 3 +# TODO: Enable in #166813 after fixing types +# - command: .buildkite/scripts/steps/check_types.sh +# label: 'Check Types' +# agents: +# queue: n2-16-spot +# timeout_in_minutes: 60 +# retry: +# automatic: +# - exit_status: '-1' +# limit: 3 - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' diff --git a/package.json b/package.json index a08963d37feeab..90d9f8aa5d2d76 100644 --- a/package.json +++ b/package.json @@ -880,7 +880,7 @@ "del": "^6.1.0", "elastic-apm-node": "^4.0.0", "email-addresses": "^5.0.0", - "execa": "^4.0.2", + "execa": "^5.1.1", "expiry-js": "0.1.7", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.1", diff --git a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts index b7babd0cb433db..3df6ac89e03d3b 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts +++ b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; +import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser'; import type { ThemeServiceStart } from '@kbn/core-theme-browser'; import type { I18nStart } from '@kbn/core-i18n-browser'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; @@ -16,8 +17,10 @@ import type { OverlayStart } from '@kbn/core-overlays-browser'; import type { NotificationsSetup, NotificationsStart } from '@kbn/core-notifications-browser'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { showErrorDialog, ToastsService } from './toasts'; +import { EventReporter, eventTypes } from './toasts/telemetry'; export interface SetupDeps { + analytics: AnalyticsServiceSetup; uiSettings: IUiSettingsClient; } @@ -25,6 +28,7 @@ export interface StartDeps { i18n: I18nStart; overlays: OverlayStart; theme: ThemeServiceStart; + analytics: AnalyticsServiceStart; targetDomElement: HTMLElement; } @@ -38,7 +42,11 @@ export class NotificationsService { this.toasts = new ToastsService(); } - public setup({ uiSettings }: SetupDeps): NotificationsSetup { + public setup({ uiSettings, analytics }: SetupDeps): NotificationsSetup { + eventTypes.forEach((eventType) => { + analytics.registerEventType(eventType); + }); + const notificationSetup = { toasts: this.toasts.setup({ uiSettings }) }; this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => { @@ -54,6 +62,7 @@ export class NotificationsService { } public start({ + analytics, i18n: i18nDep, overlays, theme, @@ -63,8 +72,11 @@ export class NotificationsService { const toastsContainer = document.createElement('div'); targetDomElement.appendChild(toastsContainer); + const eventReporter = new EventReporter({ analytics }); + return { toasts: this.toasts.start({ + eventReporter, i18n: i18nDep, overlays, theme, diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap deleted file mode 100644 index d9dc9f6c7b13dd..00000000000000 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap +++ /dev/null @@ -1,123 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`global_toast_list with duplicate elements renders the list with a single element 1`] = ` -, - "toastLifeTimeMs": 5000, - }, - ] - } -/> -`; - -exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: euiToastList 1`] = ` -, - "toastLifeTimeMs": 5000, - }, - Object { - "id": "1", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - Object { - "id": "2", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - Object { - "id": "3", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - ] - } -/> -`; - -exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: globalToastList 1`] = ` -, - "toastLifeTimeMs": 5000, - }, - Object { - "id": "1", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - Object { - "id": "2", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - Object { - "id": "3", - "text": "You've got mail!", - "title": , - "toastLifeTimeMs": 5000, - }, - ] - } -/> -`; - -exports[`renders matching snapshot 1`] = ` - -`; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap index fbb22caac4cd8b..fa435d05d72c7f 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap @@ -19,6 +19,11 @@ Array [ > = {}) { - return ; +const sharedProps = { + toasts$: EMPTY, + dismissToast: jest.fn(), + reportEvent: new EventReporter({ analytics: mockAnalytics }), +}; + +function RenderToastList(props: Partial> = {}) { + return ; } -it('renders matching snapshot', () => { - expect(shallow(render())).toMatchSnapshot(); +const dummyToastText = `You've got mail!`; +const dummyToastTitle = `AOL Notifications`; + +const createMockToast = (id: any, type?: ComponentProps['color']): Toast => ({ + id: id.toString(), + text: dummyToastText, + title: dummyToastTitle, + toastLifeTimeMs: 5000, + color: type, }); it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { @@ -31,102 +46,291 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { return unsubscribeSpy; }); - const component = render({ - toasts$: new Observable(subscribeSpy), - }); + const { unmount } = render((subscribeSpy)} />); - expect(subscribeSpy).not.toHaveBeenCalled(); - - const el = shallow(component); expect(subscribeSpy).toHaveBeenCalledTimes(1); expect(unsubscribeSpy).not.toHaveBeenCalled(); - el.unmount(); + unmount(); + expect(subscribeSpy).toHaveBeenCalledTimes(1); expect(unsubscribeSpy).toHaveBeenCalledTimes(1); }); -it('passes latest value from toasts$ to ', () => { - const el = shallow( - render({ - toasts$: from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, - }) - ); +it('uses the latest value from toasts$ passed to to render the right number of toasts', () => { + const toastObservable$ = new BehaviorSubject([{ id: '1' }, { id: '2' }]); + + render(); + + expect(screen.getAllByLabelText('Notification')).toHaveLength(2); + + act(() => { + toastObservable$.next([...toastObservable$.getValue(), { id: '3' }]); + }); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); + expect(screen.getAllByLabelText('Notification')).toHaveLength(3); }); describe('global_toast_list with duplicate elements', () => { - const dummyText = `You've got mail!`; - const dummyTitle = `AOL Notifications`; - const toast = (id: any): Toast => ({ - id: id.toString(), - text: dummyText, - title: dummyTitle, - toastLifeTimeMs: 5000, + const TOAST_DUPLICATE_COUNT = 4; + + function ToastListWithDuplicates() { + return ( + createMockToast(idx)), + ]) as any + } + /> + ); + } + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); }); - const globalToastList = shallow( - render({ - toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any, - }) - ); + it('renders the toast list with a single toast when toasts matching deduplication heuristics are passed', () => { + render(); - const euiToastList = globalToastList.find(EuiGlobalToastList); - const toastsProp = euiToastList.prop('toasts'); + const { 0: firstToast, length: toastCount } = screen.getAllByLabelText('Notification'); - it('renders the list with a single element', () => { - expect(toastsProp).toBeDefined(); - expect(toastsProp).toHaveLength(1); - expect(euiToastList).toMatchSnapshot(); + expect(toastCount).toEqual(1); + + expect(screen.getAllByText(dummyToastText)).toHaveLength(1); + + expect(firstToast.querySelector('.euiNotificationBadge')?.innerHTML).toEqual('4'); }); - it('renders the single toast with the common text', () => { - const firstRenderedToast = toastsProp![0]; - expect(firstRenderedToast.text).toBe(dummyText); + it('renders a single toast also when toast titles are mount points are used that match the deduplication heuristics', () => { + const createMockToastWithMountPoints = (id: any): Toast => ({ + id: id.toString(), + text: dummyToastText, + title: (element) => { + const a = document.createElement('a'); + a.innerText = 'Click me!'; + a.href = 'https://elastic.co'; + element.appendChild(a); + return () => element.removeChild(a); + }, + toastLifeTimeMs: 5000, + }); + + render( + + createMockToastWithMountPoints(idx) + ), + ]) as any + } + /> + ); + + const renderedToasts = screen.getAllByText(dummyToastText); + + expect(renderedToasts).toHaveLength(TOAST_DUPLICATE_COUNT); }); - it(`calls all toast's dismiss when closed`, () => { - const firstRenderedToast = toastsProp![0]; - const dismissToast = globalToastList.prop('dismissToast'); - dismissToast(firstRenderedToast); + it(`when a represented toast is closed, the provided dismiss action is called for all its internal toasts`, () => { + render(); + + const { 0: toastDismissButton, length: toastDismissButtonLength } = + screen.getAllByLabelText('Dismiss toast'); + + expect(toastDismissButtonLength).toEqual(1); + + fireEvent.click(toastDismissButton); + + act(() => { + // This is so that the toast fade out animation succesfully runs, + // only after this is the dismiss method invoked + jest.runOnlyPendingTimers(); + }); - expect(mockDismissToast).toHaveBeenCalledTimes(4); - expect(mockDismissToast).toHaveBeenCalledWith('0'); - expect(mockDismissToast).toHaveBeenCalledWith('1'); - expect(mockDismissToast).toHaveBeenCalledWith('2'); - expect(mockDismissToast).toHaveBeenCalledWith('3'); + expect(sharedProps.dismissToast).toHaveBeenCalledTimes(TOAST_DUPLICATE_COUNT); + expect(sharedProps.dismissToast).toHaveBeenCalledWith('0'); + expect(sharedProps.dismissToast).toHaveBeenCalledWith('1'); + expect(sharedProps.dismissToast).toHaveBeenCalledWith('2'); + expect(sharedProps.dismissToast).toHaveBeenCalledWith('3'); }); }); -describe('global_toast_list with duplicate elements, using MountPoints', () => { - const dummyText = `You've got mail!`; - const toast = (id: any): Toast => ({ - id: id.toString(), - text: dummyText, - title: (element) => { - const a = document.createElement('a'); - a.innerText = 'Click me!'; - a.href = 'https://elastic.co'; - element.appendChild(a); - return () => element.removeChild(a); - }, - toastLifeTimeMs: 5000, +describe('global_toast_list toast dismissal telemetry', () => { + beforeEach(() => { + jest.useFakeTimers(); }); - const globalToastList = shallow( - render({ - toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any, - }) - ); + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + it('does not invoke the reportEvent method when there is no recurring toast', async () => { + const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast'); + + const toastObservable$ = new BehaviorSubject([createMockToast(1)]); + + sharedProps.dismissToast.mockImplementation((toastId: string) => + act(() => { + const toastList = toastObservable$.getValue(); + toastObservable$.next(toastList.filter((t) => t.id !== toastId)); + }) + ); + + render(); + + const { 0: toastDismissButton, length: toastDismissButtonLength } = + screen.getAllByLabelText('Dismiss toast'); + + expect(toastDismissButtonLength).toEqual(1); + + fireEvent.click(toastDismissButton); + + act(() => { + // This is so that the toast fade out animation succesfully runs, + // only after this is the dismiss method invoked + jest.runOnlyPendingTimers(); + }); + + expect(sharedProps.dismissToast).toHaveBeenCalled(); + expect(onDimissReporterSpy).not.toBeCalled(); + + expect(screen.queryByLabelText('Notification')).toBeNull(); + }); + + it('does not invoke the reportEvent method for a recurring toast of the success type', () => { + const REPEATED_TOAST_COUNT = 2; + + const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast'); + + const toastObservable$ = new BehaviorSubject( + Array.from(new Array(2)).map((_, idx) => createMockToast(idx, 'success')) + ); + + sharedProps.dismissToast.mockImplementation((toastId: string) => + act(() => { + const toastList = toastObservable$.getValue(); + toastObservable$.next(toastList.filter((t) => t.id !== toastId)); + }) + ); + + render(); + + const { 0: toastDismissButton, length: toastDismissButtonLength } = + screen.getAllByLabelText('Dismiss toast'); + + expect(toastDismissButtonLength).toEqual(1); + + fireEvent.click(toastDismissButton); + + act(() => { + // This is so that the toast fade out animation succesfully runs, + // only after this is the dismiss method invoked + jest.runOnlyPendingTimers(); + }); + + expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT); + expect(onDimissReporterSpy).not.toBeCalled(); + + expect(screen.queryByLabelText('Notification')).toBeNull(); + }); + + it('invokes the reportEvent method for a recurring toast of allowed type that is not success', () => { + const REPEATED_TOAST_COUNT = 4; + + const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast'); + + const toastObservable$ = new BehaviorSubject( + Array.from(new Array(REPEATED_TOAST_COUNT)).map((_, idx) => createMockToast(idx, 'warning')) + ); + + sharedProps.dismissToast.mockImplementation((toastId: string) => + act(() => { + const toastList = toastObservable$.getValue(); + toastObservable$.next(toastList.filter((t) => t.id !== toastId)); + }) + ); + + render(); + + const { 0: toastDismissButton, length: toastDismissButtonLength } = + screen.getAllByLabelText('Dismiss toast'); + + expect(toastDismissButtonLength).toEqual(1); + + fireEvent.click(toastDismissButton); + + act(() => { + // This is so that the toast fade out animation succesfully runs, + // only after this is the dismiss method invoked + jest.runOnlyPendingTimers(); + }); + + expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT); + expect(onDimissReporterSpy).toHaveBeenCalledWith( + expect.objectContaining({ + recurrenceCount: REPEATED_TOAST_COUNT, + }) + ); + + expect(screen.queryByLabelText('Notification')).toBeNull(); + }); + + it('invokes the reportEvent method when the clear all button is clicked', () => { + const UNIQUE_TOASTS_COUNT = 4; + const REPEATED_COUNT_PER_UNIQUE_TOAST = 2; + + const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast'); + + const toastObservable$ = new BehaviorSubject( + Array.from(new Array(UNIQUE_TOASTS_COUNT)).reduce((acc, _, idx) => { + return acc.concat( + Array.from(new Array(REPEATED_COUNT_PER_UNIQUE_TOAST)).map(() => ({ + ...createMockToast(idx, 'warning'), + title: `${dummyToastTitle}_${idx}`, + })) + ); + }, []) + ); + + sharedProps.dismissToast.mockImplementation((toastId: string) => + act(() => { + const toastList = toastObservable$.getValue(); + toastObservable$.next(toastList.filter((t) => t.id !== toastId)); + }) + ); + + render(); + + fireEvent.click(screen.getByLabelText('Clear all toast notifications')); + + act(() => { + // This is so that the toast fade out animation succesfully runs, + // only after this is the dismiss method invoked + jest.runOnlyPendingTimers(); + }); + + expect(sharedProps.dismissToast).toHaveBeenCalledTimes( + UNIQUE_TOASTS_COUNT * REPEATED_COUNT_PER_UNIQUE_TOAST + ); + + expect(onDimissReporterSpy).toHaveBeenCalledTimes(UNIQUE_TOASTS_COUNT); - const euiToastList = globalToastList.find(EuiGlobalToastList); - const toastsProp = euiToastList.prop('toasts'); + new Array(UNIQUE_TOASTS_COUNT).forEach((_, idx) => { + expect(onDimissReporterSpy).toHaveBeenCalledWith( + expect.objectContaining({ + toastMessage: `${dummyToastTitle}_${idx}`, + recurrenceCount: REPEATED_COUNT_PER_UNIQUE_TOAST, + }) + ); + }); - it('renders the all separate elements element', () => { - expect(toastsProp).toBeDefined(); - expect(toastsProp).toHaveLength(4); - expect(euiToastList).toMatchSnapshot('euiToastList'); - expect(globalToastList).toMatchSnapshot('globalToastList'); + expect(screen.queryByLabelText('Notification')).toBeNull(); }); }); diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx index e0a5d631d3776e..39c1f1edb88895 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx @@ -7,16 +7,19 @@ */ import { EuiGlobalToastList, EuiGlobalToastListToast as EuiToast } from '@elastic/eui'; -import React from 'react'; -import { Observable, type Subscription } from 'rxjs'; +import React, { useEffect, useState, type FunctionComponent, useCallback } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import type { Toast } from '@kbn/core-notifications-browser'; import { MountWrapper } from '@kbn/core-mount-utils-browser-internal'; import { deduplicateToasts, ToastWithRichTitle } from './deduplicate_toasts'; +import { EventReporter } from './telemetry'; interface Props { toasts$: Observable; + reportEvent: EventReporter; dismissToast: (toastId: string) => void; } @@ -31,50 +34,77 @@ const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({ text: toast.text instanceof Function ? : toast.text, }); -export class GlobalToastList extends React.Component { - public state: State = { - toasts: [], - idToToasts: {}, - }; +export const GlobalToastList: FunctionComponent = ({ + toasts$, + dismissToast, + reportEvent, +}) => { + const [toasts, setToasts] = useState([]); + const [idToToasts, setIdToToasts] = useState({}); - private subscription?: Subscription; + const reportToastDismissal = useCallback( + (representedToasts: State['idToToasts'][number]) => { + // Select the first duplicate toast within the represented toast group + // given it's identical to all other recurring ones within it's group + const firstDuplicateToast = representedToasts[0]; - public componentDidMount() { - this.subscription = this.props.toasts$.subscribe((redundantToastList) => { - const { toasts, idToToasts } = deduplicateToasts(redundantToastList); - this.setState({ toasts, idToToasts }); + if ( + representedToasts.length > 1 && + firstDuplicateToast.color !== 'success' && + firstDuplicateToast.title + ) { + reportEvent.onDismissToast({ + toastMessage: + firstDuplicateToast.title instanceof Function + ? renderToStaticMarkup() + : firstDuplicateToast.title, + recurrenceCount: representedToasts.length, + toastMessageType: firstDuplicateToast.color, + }); + } + }, + [reportEvent] + ); + + useEffect(() => { + const subscription = toasts$.subscribe((redundantToastList) => { + const { toasts: reducedToasts, idToToasts: reducedIdToasts } = + deduplicateToasts(redundantToastList); + + setIdToToasts(reducedIdToasts); + setToasts(reducedToasts); }); - } - public componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } + return () => subscription.unsubscribe(); + }, [reportEvent, toasts$]); - private closeToastsRepresentedById(id: string) { - const representedToasts = this.state.idToToasts[id]; - if (representedToasts) { - representedToasts.forEach((toast) => this.props.dismissToast(toast.id)); - } - } + const closeToastsRepresentedById = useCallback( + ({ id }: EuiToast) => { + const representedToasts = idToToasts[id]; - public render() { - return ( - this.closeToastsRepresentedById(id)} - /** - * This prop is overridden by the individual toasts that are added. - * Use `Infinity` here so that it's obvious a timeout hasn't been - * provided in development. - */ - toastLifeTimeMs={Infinity} - /> - ); - } -} + if (representedToasts) { + representedToasts.forEach((toast) => dismissToast(toast.id)); + + reportToastDismissal(representedToasts); + } + }, + [dismissToast, idToToasts, reportToastDismissal] + ); + + return ( + + ); +}; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts new file mode 100644 index 00000000000000..91bf733d515e28 --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts @@ -0,0 +1,44 @@ +/* + * 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 { ComponentProps } from 'react'; +import { EuiToast } from '@elastic/eui'; +import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser'; +import { EventMetric, FieldType } from './event_types'; + +type ToastMessageType = Exclude['color'], 'success'>; + +interface EventPayload { + [FieldType.RECURRENCE_COUNT]: number; + [FieldType.TOAST_MESSAGE]: string; + [FieldType.TOAST_MESSAGE_TYPE]: ToastMessageType; +} + +export class EventReporter { + private reportEvent: AnalyticsServiceStart['reportEvent']; + + constructor({ analytics }: { analytics: AnalyticsServiceStart }) { + this.reportEvent = analytics.reportEvent; + } + + onDismissToast({ + recurrenceCount, + toastMessage, + toastMessageType, + }: { + toastMessage: string; + recurrenceCount: number; + toastMessageType: ToastMessageType; + }) { + this.reportEvent(EventMetric.TOAST_DISMISSED, { + [FieldType.RECURRENCE_COUNT]: recurrenceCount, + [FieldType.TOAST_MESSAGE]: toastMessage, + [FieldType.TOAST_MESSAGE_TYPE]: toastMessageType, + }); + } +} diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts new file mode 100644 index 00000000000000..7c4b08cd8647dd --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type RootSchema, type EventTypeOpts } from '@kbn/analytics-client'; + +export enum EventMetric { + TOAST_DISMISSED = 'global_toast_list_toast_dismissed', +} + +export enum FieldType { + RECURRENCE_COUNT = 'toast_deduplication_count', + TOAST_MESSAGE = 'toast_message', + TOAST_MESSAGE_TYPE = 'toast_message_type', +} + +const fields: Record>> = { + [FieldType.TOAST_MESSAGE]: { + [FieldType.TOAST_MESSAGE]: { + type: 'keyword', + _meta: { + description: 'toast message text', + optional: false, + }, + }, + }, + [FieldType.RECURRENCE_COUNT]: { + [FieldType.RECURRENCE_COUNT]: { + type: 'long', + _meta: { + description: 'recurrence count for particular toast message', + optional: false, + }, + }, + }, + [FieldType.TOAST_MESSAGE_TYPE]: { + [FieldType.TOAST_MESSAGE_TYPE]: { + type: 'keyword', + _meta: { + description: 'toast message type', + optional: false, + }, + }, + }, +}; + +export const eventTypes: Array>> = [ + { + eventType: EventMetric.TOAST_DISMISSED, + schema: { + ...fields[FieldType.TOAST_MESSAGE], + ...fields[FieldType.RECURRENCE_COUNT], + ...fields[FieldType.TOAST_MESSAGE_TYPE], + }, + }, +]; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts new file mode 100644 index 00000000000000..dad98bcbd54bed --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts @@ -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. + */ + +export { EventReporter } from './event_reporter'; +export { eventTypes } from './event_types'; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx index 4e4ef668e97783..6509c75b84533c 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx @@ -13,6 +13,8 @@ import { ToastsApi } from './toasts_api'; import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks'; import { themeServiceMock } from '@kbn/core-theme-browser-mocks'; import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; +import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; +import { EventReporter } from './telemetry'; const mockI18n: any = { Context: function I18nContext() { @@ -22,6 +24,9 @@ const mockI18n: any = { const mockOverlays = overlayServiceMock.createStartContract(); const mockTheme = themeServiceMock.createStartContract(); +const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart(); + +const eventReporter = new EventReporter({ analytics: mockAnalytics }); describe('#setup()', () => { it('returns a ToastsApi', () => { @@ -41,7 +46,13 @@ describe('#start()', () => { expect(mockReactDomRender).not.toHaveBeenCalled(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); + toasts.start({ + i18n: mockI18n, + theme: mockTheme, + targetDomElement, + overlays: mockOverlays, + eventReporter, + }); expect(mockReactDomRender.mock.calls).toMatchSnapshot(); }); @@ -53,7 +64,13 @@ describe('#start()', () => { toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }) ).toBeInstanceOf(ToastsApi); expect( - toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }) + toasts.start({ + i18n: mockI18n, + theme: mockTheme, + targetDomElement, + overlays: mockOverlays, + eventReporter, + }) ).toBeInstanceOf(ToastsApi); }); }); @@ -65,7 +82,13 @@ describe('#stop()', () => { const toasts = new ToastsService(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); + toasts.start({ + i18n: mockI18n, + theme: mockTheme, + targetDomElement, + overlays: mockOverlays, + eventReporter, + }); expect(mockReactDomUnmount).not.toHaveBeenCalled(); toasts.stop(); @@ -84,7 +107,13 @@ describe('#stop()', () => { const toasts = new ToastsService(); toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() }); - toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays }); + toasts.start({ + i18n: mockI18n, + theme: mockTheme, + targetDomElement, + overlays: mockOverlays, + eventReporter, + }); toasts.stop(); expect(targetDomElement.childNodes).toHaveLength(0); }); diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx index 9656b6764b7155..63ede7bcb906eb 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx @@ -16,6 +16,7 @@ import type { OverlayStart } from '@kbn/core-overlays-browser'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { GlobalToastList } from './global_toast_list'; import { ToastsApi } from './toasts_api'; +import { EventReporter } from './telemetry'; interface SetupDeps { uiSettings: IUiSettingsClient; @@ -25,6 +26,7 @@ interface StartDeps { i18n: I18nStart; overlays: OverlayStart; theme: ThemeServiceStart; + eventReporter: EventReporter; targetDomElement: HTMLElement; } @@ -37,7 +39,7 @@ export class ToastsService { return this.api!; } - public start({ i18n, overlays, theme, targetDomElement }: StartDeps) { + public start({ eventReporter, i18n, overlays, theme, targetDomElement }: StartDeps) { this.api!.start({ overlays, i18n, theme }); this.targetDomElement = targetDomElement; @@ -46,6 +48,7 @@ export class ToastsService { this.api!.remove(toastId)} toasts$={this.api!.get$()} + reportEvent={eventReporter} /> , targetDomElement diff --git a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json index 09392c1805f8f2..0250d4b80488be 100644 --- a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json +++ b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json @@ -29,6 +29,9 @@ "@kbn/core-theme-browser-mocks", "@kbn/core-mount-utils-browser", "@kbn/react-kibana-context-render", + "@kbn/core-analytics-browser", + "@kbn/core-analytics-browser-mocks", + "@kbn/analytics-client", ], "exclude": [ "target/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.test.ts b/packages/core/root/core-root-browser-internal/src/core_system.test.ts index d77ccaedb52793..b4cdd4b1d965b0 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.test.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.test.ts @@ -460,6 +460,7 @@ describe('#start()', () => { overlays: expect.any(Object), theme: expect.any(Object), targetDomElement: expect.any(HTMLElement), + analytics: expect.any(Object), }); }); diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index cf9a172479b312..0980c84aab89f6 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -239,7 +239,7 @@ export class CoreSystem { this.chrome.setup({ analytics }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const settings = this.settings.setup({ http, injectedMetadata }); - const notifications = this.notifications.setup({ uiSettings }); + const notifications = this.notifications.setup({ uiSettings, analytics }); const customBranding = this.customBranding.setup({ injectedMetadata }); const application = this.application.setup({ http, analytics }); @@ -305,6 +305,7 @@ export class CoreSystem { targetDomElement: overlayTargetDomElement, }); const notifications = await this.notifications.start({ + analytics, i18n, overlays, theme, diff --git a/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx b/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx index 201942bc89d446..c101ef175b3958 100644 --- a/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx +++ b/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx @@ -16,7 +16,7 @@ const IMAGE = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8 describe('ChangeImageLink', () => { const defaultProps: ChangeImageLinkProps = { field: { - name: 'test', + id: 'test', type: 'image', ariaAttributes: { ariaLabel: 'test', diff --git a/packages/kbn-management/settings/components/field_row/footer/index.ts b/packages/kbn-management/settings/components/field_row/footer/index.ts index 0322c720103804..fc8cac296885c0 100644 --- a/packages/kbn-management/settings/components/field_row/footer/index.ts +++ b/packages/kbn-management/settings/components/field_row/footer/index.ts @@ -7,4 +7,5 @@ */ export { FieldInputFooter, type FieldInputFooterProps } from './input_footer'; -export { InputResetLink, type FieldResetLinkProps } from './reset_link'; +export { InputResetLink } from './reset_link'; +export type { InputResetLinkProps } from './reset_link'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index 487ed95672d830..d6bb3879669aea 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -998,12 +998,12 @@ export default function ({ ) { // will simulate autocomplete on 'GET /a/b/' with a filter by index return { - tokenPath: context.urlTokenPath.slice(0, -1), - predicate: (term) => term.meta === 'index', + tokenPath: context.urlTokenPath?.slice(0, -1), + predicate: (term: any) => term.meta === 'index', }; } else { // will do nothing special - return { tokenPath: context.urlTokenPath, predicate: (term) => true }; + return { tokenPath: context.urlTokenPath, predicate: () => true }; } })(); diff --git a/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts index fec2d60e017631..4bda6b379c7902 100644 --- a/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts +++ b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts @@ -29,10 +29,6 @@ export const dashboardCopyToDashboardActionStrings = { i18n.translate('dashboard.panel.copyToDashboard.existingDashboardOptionLabel', { defaultMessage: 'Existing dashboard', }), - getDescription: () => - i18n.translate('dashboard.panel.copyToDashboard.description', { - defaultMessage: 'Choose the destination dashboard.', - }), }; export const dashboardAddToLibraryActionStrings = { diff --git a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx index 577ac2dc7a18e9..a44c3cd94e8781 100644 --- a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_action.tsx @@ -8,10 +8,10 @@ import React from 'react'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; -import type { PresentationUtilPluginStart } from '@kbn/presentation-util-plugin/public'; import { pluginServices } from '../services/plugin_services'; import { CopyToDashboardModal } from './copy_to_dashboard_modal'; @@ -39,16 +39,12 @@ export class CopyToDashboardAction implements Action session.close()} dashboardId={(embeddable.parent as DashboardContainer).getDashboardSavedObjectId()} embeddable={embeddable} />, - { theme$: this.theme$ } + { theme, i18n } ), { maxWidth: 400, diff --git a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx index 2a41cc5a2bd8eb..0563b4ac8aab64 100644 --- a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx @@ -9,21 +9,21 @@ import React, { useCallback, useState } from 'react'; import { omit } from 'lodash'; import { - EuiText, EuiRadio, - EuiPanel, EuiButton, EuiSpacer, EuiFormRow, - EuiFocusTrap, EuiModalBody, EuiButtonEmpty, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOutsideClickDetector, } from '@elastic/eui'; -import { IEmbeddable, PanelNotFoundError } from '@kbn/embeddable-plugin/public'; +import { + EmbeddablePackageState, + IEmbeddable, + PanelNotFoundError, +} from '@kbn/embeddable-plugin/public'; import { LazyDashboardPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; import { DashboardPanelState } from '../../common'; @@ -33,7 +33,6 @@ import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_stri import { createDashboardEditUrl, CREATE_NEW_DASHBOARD_URL } from '../dashboard_constants'; interface CopyToDashboardModalProps { - PresentationUtilContext: React.FC; embeddable: IEmbeddable; dashboardId?: string; closeModal: () => void; @@ -42,7 +41,6 @@ interface CopyToDashboardModalProps { const DashboardPicker = withSuspense(LazyDashboardPicker); export function CopyToDashboardModal({ - PresentationUtilContext, dashboardId, embeddable, closeModal, @@ -65,11 +63,15 @@ export function CopyToDashboardModal({ throw new PanelNotFoundError(); } - const state = { + const state: EmbeddablePackageState = { type: embeddable.type, input: { ...omit(panelToCopy.explicitInput, 'id'), }, + size: { + width: panelToCopy.gridData.w, + height: panelToCopy.gridData.h, + }, }; const path = @@ -88,90 +90,73 @@ export function CopyToDashboardModal({ const descriptionId = 'copyToDashboardDescription'; return ( - - -
- - - - {dashboardCopyToDashboardActionStrings.getDisplayName()} - - +
+ + + {dashboardCopyToDashboardActionStrings.getDisplayName()} + + - - <> - -

{dashboardCopyToDashboardActionStrings.getDescription()}

-
- - - -
- {canEditExisting && ( - <> - setDashboardOption('existing')} - /> -
- setSelectedDashboard(dashboard)} - /> -
- - - )} - {canCreateNew && ( - <> - setDashboardOption('new')} - /> - - - )} -
-
-
- -
+ + <> + +
+ {canEditExisting && ( + <> + setDashboardOption('existing')} + /> + +
+ { + setSelectedDashboard(dashboard); + setDashboardOption('existing'); + }} + /> +
+ + + )} + {canCreateNew && ( + <> + setDashboardOption('new')} + /> + + + )} +
+
+ +
- - closeModal()}> - {dashboardCopyToDashboardActionStrings.getCancelButtonName()} - - - {dashboardCopyToDashboardActionStrings.getAcceptButtonName()} - - - -
- - + + closeModal()}> + {dashboardCopyToDashboardActionStrings.getCancelButtonName()} + + + {dashboardCopyToDashboardActionStrings.getAcceptButtonName()} + + +
); } diff --git a/src/plugins/dashboard/public/dashboard_actions/index.ts b/src/plugins/dashboard/public/dashboard_actions/index.ts index 4f0daff8c33908..21cde70691dbd4 100644 --- a/src/plugins/dashboard/public/dashboard_actions/index.ts +++ b/src/plugins/dashboard/public/dashboard_actions/index.ts @@ -32,7 +32,7 @@ export const buildAllDashboardActions = async ({ plugins, allowByValueEmbeddables, }: BuildAllDashboardActionsProps) => { - const { uiActions, share, presentationUtil, savedObjectsTaggingOss, contentManagement } = plugins; + const { uiActions, share, savedObjectsTaggingOss, contentManagement } = plugins; const clonePanelAction = new ClonePanelAction(); uiActions.registerAction(clonePanelAction); @@ -73,7 +73,7 @@ export const buildAllDashboardActions = async ({ uiActions.registerAction(libraryNotificationAction); uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, libraryNotificationAction.id); - const copyToDashboardAction = new CopyToDashboardAction(presentationUtil.ContextProvider); + const copyToDashboardAction = new CopyToDashboardAction(core); uiActions.registerAction(copyToDashboardAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, copyToDashboardAction.id); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts index 244e816620ae78..c71e5cfc51d750 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts @@ -277,7 +277,7 @@ test('creates new embeddable with incoming embeddable if id does not match exist .fn() .mockReturnValue(mockContactCardFactory); - await createDashboard({ + const dashboard = await createDashboard({ getIncomingEmbeddable: () => incomingEmbeddable, getInitialInput: () => ({ panels: { @@ -301,6 +301,75 @@ test('creates new embeddable with incoming embeddable if id does not match exist }), expect.any(Object) ); + expect(dashboard!.getState().explicitInput.panels.i_match.explicitInput).toStrictEqual( + expect.objectContaining({ + id: 'i_match', + firstName: 'wow look at this new panel wow', + }) + ); + expect(dashboard!.getState().explicitInput.panels.i_do_not_match.explicitInput).toStrictEqual( + expect.objectContaining({ + id: 'i_do_not_match', + firstName: 'phew... I will not be replaced', + }) + ); + + // expect panel to be created with the default size. + expect(dashboard!.getState().explicitInput.panels.i_match.gridData.w).toBe(24); + expect(dashboard!.getState().explicitInput.panels.i_match.gridData.h).toBe(15); +}); + +test('creates new embeddable with specified size if size is provided', async () => { + const incomingEmbeddable: EmbeddablePackageState = { + type: CONTACT_CARD_EMBEDDABLE, + input: { + id: 'new_panel', + firstName: 'what a tiny lil panel', + } as ContactCardEmbeddableInput, + size: { width: 1, height: 1 }, + embeddableId: 'new_panel', + }; + const mockContactCardFactory = { + create: jest.fn().mockReturnValue({ destroy: jest.fn() }), + getDefaultInput: jest.fn().mockResolvedValue({}), + }; + pluginServices.getServices().embeddable.getEmbeddableFactory = jest + .fn() + .mockReturnValue(mockContactCardFactory); + + const dashboard = await createDashboard({ + getIncomingEmbeddable: () => incomingEmbeddable, + getInitialInput: () => ({ + panels: { + i_do_not_match: getSampleDashboardPanel({ + explicitInput: { + id: 'i_do_not_match', + firstName: 'phew... I will not be replaced', + }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, + }), + }); + + // flush promises + await new Promise((r) => setTimeout(r, 1)); + + expect(mockContactCardFactory.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new_panel', + firstName: 'what a tiny lil panel', + }), + expect.any(Object) + ); + expect(dashboard!.getState().explicitInput.panels.new_panel.explicitInput).toStrictEqual( + expect.objectContaining({ + id: 'new_panel', + firstName: 'what a tiny lil panel', + }) + ); + expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.w).toBe(1); + expect(dashboard!.getState().explicitInput.panels.new_panel.gridData.h).toBe(1); }); test('creates a control group from the control group factory and waits for it to be initialized', async () => { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 2403a569a80bbb..70e81ca7a76d1a 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { v4 } from 'uuid'; import { Subject } from 'rxjs'; import { cloneDeep, identity, pickBy } from 'lodash'; @@ -19,14 +20,20 @@ import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public' import { type ControlGroupContainer, ControlGroupOutput } from '@kbn/controls-plugin/public'; import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; -import { DashboardContainerInput } from '../../../../common'; import { DashboardContainer } from '../dashboard_container'; import { pluginServices } from '../../../services/plugin_services'; import { DashboardCreationOptions } from '../dashboard_container_factory'; +import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { startSyncingDashboardDataViews } from './data_views/sync_dashboard_data_views'; +import { findTopLeftMostOpenSpace } from '../../component/panel/dashboard_panel_placement'; import { LoadDashboardReturn } from '../../../services/dashboard_content_management/types'; import { syncUnifiedSearchState } from './unified_search/sync_dashboard_unified_search_state'; -import { DEFAULT_DASHBOARD_INPUT, GLOBAL_STATE_STORAGE_KEY } from '../../../dashboard_constants'; +import { + DEFAULT_DASHBOARD_INPUT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, + GLOBAL_STATE_STORAGE_KEY, +} from '../../../dashboard_constants'; import { startSyncingDashboardControlGroup } from './controls/dashboard_control_group_integration'; import { startDashboardSearchSessionIntegration } from './search_sessions/start_dashboard_search_session_integration'; import { DashboardPublicState } from '../../types'; @@ -274,13 +281,45 @@ export const initializeDashboard = async ({ scrolltoIncomingEmbeddable(container, incomingEmbeddable.embeddableId as string) ); } else { - // otherwise this incoming embeddable is brand new and can be added via the default method after the dashboard container is created. + // otherwise this incoming embeddable is brand new and can be added after the dashboard container is created. + untilDashboardReady().then(async (container) => { - const embeddable = await container.addNewEmbeddable( - incomingEmbeddable.type, - incomingEmbeddable.input - ); - scrolltoIncomingEmbeddable(container, embeddable.id); + const createdEmbeddable = await (async () => { + // if there is no width or height we can add the panel using the default behaviour. + if (!incomingEmbeddable.size) { + return await container.addNewEmbeddable( + incomingEmbeddable.type, + incomingEmbeddable.input + ); + } + + // if the incoming embeddable has an explicit width or height we add the panel to the grid directly. + const { width, height } = incomingEmbeddable.size; + const currentPanels = container.getInput().panels; + const embeddableId = incomingEmbeddable.embeddableId ?? v4(); + const { newPanelPlacement } = findTopLeftMostOpenSpace({ + width: width ?? DEFAULT_PANEL_WIDTH, + height: height ?? DEFAULT_PANEL_HEIGHT, + currentPanels, + }); + const newPanelState: DashboardPanelState = { + explicitInput: { ...incomingEmbeddable.input, id: embeddableId }, + type: incomingEmbeddable.type, + gridData: { + ...newPanelPlacement, + i: embeddableId, + }, + }; + container.updateInput({ + panels: { + ...container.getInput().panels, + [newPanelState.explicitInput.id]: newPanelState, + }, + }); + + return await container.untilEmbeddableLoaded(embeddableId); + })(); + scrolltoIncomingEmbeddable(container, createdEmbeddable.id); }); } } diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index 31cc2b1aab2369..79aa7716b0160a 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -65,7 +65,9 @@ "@kbn/shared-ux-prompt-not-found", "@kbn/content-management-content-editor", "@kbn/serverless", - "@kbn/no-data-page-plugin" + "@kbn/no-data-page-plugin", + "@kbn/react-kibana-mount", + "@kbn/core-lifecycle-browser" ], "exclude": ["target/**/*"] } diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 74ee31ba71104d..db6085f6ea2768 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -42,6 +42,10 @@ export interface EmbeddablePackageState { type: string; input: Optional | Optional; embeddableId?: string; + size?: { + width?: number; + height?: number; + }; /** * Pass current search session id when navigating to an editor, diff --git a/src/plugins/presentation_util/kibana.jsonc b/src/plugins/presentation_util/kibana.jsonc index 1dfa765354cf91..91ac6c41943780 100644 --- a/src/plugins/presentation_util/kibana.jsonc +++ b/src/plugins/presentation_util/kibana.jsonc @@ -10,6 +10,7 @@ "requiredPlugins": [ "savedObjects", "kibanaReact", + "contentManagement", "embeddable", "expressions", "dataViews", diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx deleted file mode 100644 index 0207fe1dc34582..00000000000000 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ /dev/null @@ -1,105 +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 React, { useState, useEffect } from 'react'; - -import { i18n } from '@kbn/i18n'; - -import { EuiComboBox } from '@elastic/eui'; -import { pluginServices } from '../services'; - -export interface DashboardPickerProps { - onChange: (dashboard: { name: string; id: string } | null) => void; - isDisabled: boolean; - idsToOmit?: string[]; -} - -interface DashboardOption { - label: string; - value: string; -} - -export function DashboardPicker(props: DashboardPickerProps) { - const [dashboardOptions, setDashboardOptions] = useState([]); - const [isLoadingDashboards, setIsLoadingDashboards] = useState(true); - const [selectedDashboard, setSelectedDashboard] = useState(null); - const [query, setQuery] = useState(''); - - const { isDisabled, onChange } = props; - const { dashboards } = pluginServices.getHooks(); - const { findDashboardsByTitle } = dashboards.useService(); - - useEffect(() => { - // We don't want to manipulate the React state if the component has been unmounted - // while we wait for the saved objects to return. - let cleanedUp = false; - - const fetchDashboards = async () => { - setIsLoadingDashboards(true); - setDashboardOptions([]); - - const objects = await findDashboardsByTitle(query ? `${query}*` : ''); - - if (cleanedUp) { - return; - } - - if (objects) { - setDashboardOptions( - objects - .filter((d) => !d.managed && !(props.idsToOmit ?? []).includes(d.id)) - .map((d) => ({ - value: d.id, - label: d.attributes.title, - 'data-test-subj': `dashboard-picker-option-${d.attributes.title.replaceAll( - ' ', - '-' - )}`, - })) - ); - } - - setIsLoadingDashboards(false); - }; - - fetchDashboards(); - - return () => { - cleanedUp = true; - }; - }, [findDashboardsByTitle, query, props.idsToOmit]); - - return ( - { - if (e.length) { - setSelectedDashboard({ value: e[0].value || '', label: e[0].label }); - onChange({ name: e[0].label, id: e[0].value || '' }); - } else { - setSelectedDashboard(null); - onChange(null); - } - }} - onSearchChange={setQuery} - isDisabled={isDisabled} - isLoading={isLoadingDashboards} - compressed={true} - /> - ); -} - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default DashboardPicker; diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx b/src/plugins/presentation_util/public/components/dashboard_picker/dashboard_picker.stories.tsx similarity index 100% rename from src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx rename to src/plugins/presentation_util/public/components/dashboard_picker/dashboard_picker.stories.tsx diff --git a/src/plugins/presentation_util/public/components/dashboard_picker/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker/dashboard_picker.tsx new file mode 100644 index 00000000000000..ee72f9e03c40e6 --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_picker/dashboard_picker.tsx @@ -0,0 +1,182 @@ +/* + * 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 { debounce } from 'lodash'; +import { css } from '@emotion/react'; +import React, { useState, useEffect, useMemo } from 'react'; + +import { + EuiText, + useEuiTheme, + EuiSelectable, + EuiInputPopover, + EuiPopoverTitle, + EuiFieldSearch, + EuiHighlight, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ToolbarButton } from '@kbn/kibana-react-plugin/public'; +import { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common'; + +import { pluginServices } from '../../services'; + +export interface DashboardPickerProps { + onChange: (dashboard: { name: string; id: string } | null) => void; + isDisabled: boolean; + idsToOmit?: string[]; +} + +interface DashboardOption { + label: string; + value: string; +} + +type DashboardHit = SavedObjectCommon<{ title: string }>; + +export function DashboardPicker({ isDisabled, onChange, idsToOmit }: DashboardPickerProps) { + const { euiTheme } = useEuiTheme(); + + const [isLoading, setIsLoading] = useState(true); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const [dashboardHits, setDashboardHits] = useState([]); + const [dashboardOptions, setDashboardOptions] = useState([]); + + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + const [selectedDashboard, setSelectedDashboard] = useState(null); + + const { + contentManagement: { client: cmClient }, + } = pluginServices.getServices(); + + /** + * Debounce the query to avoid many calls to content management. + */ + const debouncedSetQuery = useMemo( + () => debounce((latestQuery) => setDebouncedQuery(latestQuery), 150), + [] + ); + useEffect(() => { + debouncedSetQuery(query); + }, [debouncedSetQuery, query]); + + /** + * Run query to search for Dashboards when debounced query changes. + */ + useEffect(() => { + let canceled = false; + + (async () => { + setIsLoading(true); + + const response = await cmClient.mSearch({ + contentTypes: [{ contentTypeId: 'dashboard' }], + query: { + text: debouncedQuery ? `${debouncedQuery}*` : undefined, + limit: 30, + }, + }); + if (canceled) return; + if (response && response.hits) { + setDashboardHits(response.hits); + } + + setIsLoading(false); + })(); + + return () => { + canceled = true; + }; + }, [debouncedQuery, cmClient]); + + /** + * Format items with dashboard hits and selected option + */ + useEffect(() => { + setDashboardOptions( + dashboardHits + .filter((d) => !d.managed && !(idsToOmit ?? []).includes(d.id)) + .map((d) => ({ + value: d.id, + label: d.attributes.title, + checked: d.id === selectedDashboard?.value ? 'on' : undefined, + 'data-test-subj': `dashboard-picker-option-${d.attributes.title.replaceAll(' ', '-')}`, + })) + ); + }, [dashboardHits, idsToOmit, selectedDashboard]); + + return ( + setIsPopoverOpen(!isPopoverOpen)} + > + + {selectedDashboard?.label ?? + i18n.translate('presentationUtil.dashboardPicker.noDashboardOptionLabel', { + defaultMessage: 'Select dashboard', + })} + + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + > + + setQuery(event.target.value)} + value={query} + data-test-subj="dashboard-picker-search" + placeholder={i18n.translate( + 'presentationUtil.dashboardPicker.searchDashboardPlaceholder', + { + defaultMessage: 'Search dashboards...', + } + )} + /> + + + { + setIsPopoverOpen(false); + + const nextSelectedDashboard: DashboardOption | null = + selected.value === selectedDashboard?.value ? null : selected; + setSelectedDashboard(nextSelectedDashboard); + onChange( + nextSelectedDashboard + ? { name: nextSelectedDashboard.label, id: nextSelectedDashboard.value } + : null + ); + }} + renderOption={(option) => {option.label}} + > + {(list) =>
{list}
} +
+
+ ); +} + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default DashboardPicker; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index fb66c9c5b2be62..cff38e8a79d2bf 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -32,7 +32,7 @@ export const LazyLabsBeakerButton = React.lazy(() => import('./labs/labs_beaker_ export const LazyLabsFlyout = React.lazy(() => import('./labs/labs_flyout')); -export const LazyDashboardPicker = React.lazy(() => import('./dashboard_picker')); +export const LazyDashboardPicker = React.lazy(() => import('./dashboard_picker/dashboard_picker')); export const LazySavedObjectSaveModalDashboard = React.lazy( () => import('./saved_object_save_modal_dashboard') diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.scss b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.scss deleted file mode 100644 index db07073bf1a3a7..00000000000000 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.scss +++ /dev/null @@ -1,5 +0,0 @@ -.savAddDashboard__searchDashboards { - margin-left: $euiSizeL; - margin-top: $euiSizeXS; - width: 300px; -} diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx index a13d7f0c76d5bd..032bd022afd3c2 100644 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx @@ -16,8 +16,6 @@ import { pluginServices } from '../services'; import { SaveModalDashboardProps } from './types'; import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector'; -import './saved_object_save_modal_dashboard.scss'; - function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) { const { documentInfo, tagOptions, objectType, onClose, canSaveByReference } = props; const { id: documentId } = documentInfo; diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx index 33773dbc94faab..ce9d82ddf1db94 100644 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx @@ -22,9 +22,7 @@ import { EuiCheckbox, } from '@elastic/eui'; -import DashboardPicker, { DashboardPickerProps } from './dashboard_picker'; - -import './saved_object_save_modal_dashboard.scss'; +import DashboardPicker, { DashboardPickerProps } from './dashboard_picker/dashboard_picker'; export interface SaveModalDashboardSelectorProps { copyOnSave: boolean; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index f5994b3da82e2f..280fc4b979ce0d 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -10,11 +10,7 @@ import { ExpressionFunction } from '@kbn/expressions-plugin/common'; import { PresentationUtilPlugin } from './plugin'; import { pluginServices } from './services'; -export type { - PresentationCapabilitiesService, - PresentationDashboardsService, - PresentationLabsService, -} from './services'; +export type { PresentationCapabilitiesService, PresentationLabsService } from './services'; export type { KibanaPluginServiceFactory, diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index 2c59d3ad55aadc..b8a69d21916a8d 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -9,15 +9,13 @@ import { CoreStart } from '@kbn/core/public'; import { PresentationUtilPluginStart } from './types'; import { pluginServices } from './services'; -import { registry } from './services/plugin_services'; +import { registry as stubRegistry } from './services/plugin_services.story'; import { ReduxToolsPackage, registerExpressionsLanguage } from '.'; import { createReduxEmbeddableTools } from './redux_tools/redux_embeddables/create_redux_embeddable_tools'; import { createReduxTools } from './redux_tools/create_redux_tools'; const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { - pluginServices.setRegistry( - registry.start({ coreStart, startPlugins: { dataViews: {}, uiActions: {} } as any }) - ); + pluginServices.setRegistry(stubRegistry.start({})); const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), diff --git a/src/plugins/presentation_util/public/services/content_management/content_management.stub.ts b/src/plugins/presentation_util/public/services/content_management/content_management.stub.ts new file mode 100644 index 00000000000000..45e73c629bbabd --- /dev/null +++ b/src/plugins/presentation_util/public/services/content_management/content_management.stub.ts @@ -0,0 +1,25 @@ +/* + * 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 { PluginServiceFactory } from '../create'; +import { PresentationContentManagementService } from './types'; + +type ContentManagementServiceFactory = PluginServiceFactory; + +export const contentManagementServiceFactory: ContentManagementServiceFactory = () => ({ + client: { + get: jest.fn(), + get$: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + search: jest.fn(), + search$: jest.fn(), + mSearch: jest.fn(), + } as unknown as PresentationContentManagementService['client'], +}); diff --git a/src/plugins/presentation_util/public/services/content_management/content_management_service.ts b/src/plugins/presentation_util/public/services/content_management/content_management_service.ts new file mode 100644 index 00000000000000..04876f0d8414ad --- /dev/null +++ b/src/plugins/presentation_util/public/services/content_management/content_management_service.ts @@ -0,0 +1,24 @@ +/* + * 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 { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationContentManagementService } from './types'; +import { KibanaPluginServiceFactory } from '../create'; + +export type PresentationContentManagementServiceFactory = KibanaPluginServiceFactory< + PresentationContentManagementService, + PresentationUtilPluginStartDeps +>; + +export const contentManagementServiceFactory: PresentationContentManagementServiceFactory = ({ + startPlugins, +}) => { + return { + client: startPlugins.contentManagement.client, + }; +}; diff --git a/src/plugins/presentation_util/public/services/content_management/types.ts b/src/plugins/presentation_util/public/services/content_management/types.ts new file mode 100644 index 00000000000000..a8b0527d84df89 --- /dev/null +++ b/src/plugins/presentation_util/public/services/content_management/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; + +export interface PresentationContentManagementService { + client: ContentManagementPublicStart['client']; +} diff --git a/src/plugins/presentation_util/public/services/dashboards/dashboards.stub.ts b/src/plugins/presentation_util/public/services/dashboards/dashboards.stub.ts deleted file mode 100644 index d1dabd027aa2f6..00000000000000 --- a/src/plugins/presentation_util/public/services/dashboards/dashboards.stub.ts +++ /dev/null @@ -1,37 +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 { PluginServiceFactory } from '../create'; -import { PresentationDashboardsService } from './types'; - -// TODO (clint): Create set of dashboards to stub and return. - -type DashboardsServiceFactory = PluginServiceFactory; - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const dashboardsServiceFactory: DashboardsServiceFactory = () => ({ - findDashboards: async (query: string = '', _fields: string[] = []) => { - if (!query) { - return []; - } - - await sleep(2000); - return []; - }, - findDashboardsByTitle: async (title: string) => { - if (!title) { - return []; - } - - await sleep(2000); - return []; - }, -}); diff --git a/src/plugins/presentation_util/public/services/dashboards/dashboards_service.ts b/src/plugins/presentation_util/public/services/dashboards/dashboards_service.ts deleted file mode 100644 index 68facb8f31d94b..00000000000000 --- a/src/plugins/presentation_util/public/services/dashboards/dashboards_service.ts +++ /dev/null @@ -1,40 +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 { PresentationUtilPluginStartDeps } from '../../types'; -import { KibanaPluginServiceFactory } from '../create'; -import type { PresentationDashboardsService } from './types'; - -export type DashboardsServiceFactory = KibanaPluginServiceFactory< - PresentationDashboardsService, - PresentationUtilPluginStartDeps ->; - -export interface PartialDashboardAttributes { - title: string; -} -export const dashboardsServiceFactory: DashboardsServiceFactory = ({ coreStart }) => { - const findDashboards = async (query: string = '', fields: string[] = []) => { - const { find } = coreStart.savedObjects.client; - - const { savedObjects } = await find({ - type: 'dashboard', - search: `${query}*`, - searchFields: fields, - }); - - return savedObjects; - }; - - const findDashboardsByTitle = async (title: string = '') => findDashboards(title, ['title']); - - return { - findDashboards, - findDashboardsByTitle, - }; -}; diff --git a/src/plugins/presentation_util/public/services/dashboards/types.ts b/src/plugins/presentation_util/public/services/dashboards/types.ts deleted file mode 100644 index a4fd17a2818004..00000000000000 --- a/src/plugins/presentation_util/public/services/dashboards/types.ts +++ /dev/null @@ -1,20 +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 { SimpleSavedObject } from '@kbn/core/public'; -import { PartialDashboardAttributes } from './dashboards_service'; - -export interface PresentationDashboardsService { - findDashboards: ( - query: string, - fields: string[] - ) => Promise>>; - findDashboardsByTitle: ( - title: string - ) => Promise>>; -} diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index b95bb666ae4e21..0989197aae8f0c 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -8,8 +8,4 @@ export { pluginServices } from './plugin_services'; -export type { - PresentationCapabilitiesService, - PresentationDashboardsService, - PresentationLabsService, -} from './types'; +export type { PresentationCapabilitiesService, PresentationLabsService } from './types'; diff --git a/src/plugins/presentation_util/public/services/plugin_services.story.ts b/src/plugins/presentation_util/public/services/plugin_services.story.ts index b95b99e1dbca87..14ce570364bd38 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.story.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.story.ts @@ -16,7 +16,7 @@ import { PresentationUtilServices } from './types'; import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; -import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; +import { contentManagementServiceFactory } from './content_management/content_management.stub'; import { labsServiceFactory } from './labs/labs.story'; import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; @@ -24,7 +24,7 @@ export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), - dashboards: new PluginServiceProvider(dashboardsServiceFactory), + contentManagement: new PluginServiceProvider(contentManagementServiceFactory), uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/services/plugin_services.stub.ts b/src/plugins/presentation_util/public/services/plugin_services.stub.ts index 427fbf9a3b6eb1..409dcbd12ad6df 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.stub.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.stub.ts @@ -13,15 +13,15 @@ import { PresentationUtilPluginStart, registerExpressionsLanguage } from '..'; import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; -import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; import { labsServiceFactory } from './labs/labs.story'; import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; +import { contentManagementServiceFactory } from './content_management/content_management.stub'; export const providers: PluginServiceProviders = { + contentManagement: new PluginServiceProvider(contentManagementServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), - dashboards: new PluginServiceProvider(dashboardsServiceFactory), uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/services/plugin_services.ts b/src/plugins/presentation_util/public/services/plugin_services.ts index 266912446b63c9..a66d4d75b648f9 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.ts @@ -17,7 +17,7 @@ import { PresentationUtilPluginStartDeps } from '../types'; import { capabilitiesServiceFactory } from './capabilities/capabilities_service'; import { dataViewsServiceFactory } from './data_views/data_views_service'; -import { dashboardsServiceFactory } from './dashboards/dashboards_service'; +import { contentManagementServiceFactory } from './content_management/content_management_service'; import { uiActionsServiceFactory } from './ui_actions/ui_actions_service'; import { labsServiceFactory } from './labs/labs_service'; import { PresentationUtilServices } from './types'; @@ -29,8 +29,8 @@ export const providers: PluginServiceProviders< capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), - dashboards: new PluginServiceProvider(dashboardsServiceFactory), uiActions: new PluginServiceProvider(uiActionsServiceFactory), + contentManagement: new PluginServiceProvider(contentManagementServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/types.ts b/src/plugins/presentation_util/public/services/types.ts index 861d4f55068ac2..a11d98a463e538 100644 --- a/src/plugins/presentation_util/public/services/types.ts +++ b/src/plugins/presentation_util/public/services/types.ts @@ -7,21 +7,17 @@ */ import { PresentationLabsService } from './labs/types'; -import { PresentationDashboardsService } from './dashboards/types'; import { PresentationCapabilitiesService } from './capabilities/types'; import { PresentationDataViewsService } from './data_views/types'; import { PresentationUiActionsService } from './ui_actions/types'; +import { PresentationContentManagementService } from './content_management/types'; export interface PresentationUtilServices { + contentManagement: PresentationContentManagementService; capabilities: PresentationCapabilitiesService; - dashboards: PresentationDashboardsService; dataViews: PresentationDataViewsService; uiActions: PresentationUiActionsService; labs: PresentationLabsService; } -export type { - PresentationCapabilitiesService, - PresentationDashboardsService, - PresentationLabsService, -}; +export type { PresentationCapabilitiesService, PresentationLabsService }; diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 3b2785da82c0eb..589d4101bdf806 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public/plugin'; import { registerExpressionsLanguage } from '.'; @@ -23,6 +24,7 @@ export interface PresentationUtilPluginStart { export interface PresentationUtilPluginSetupDeps {} export interface PresentationUtilPluginStartDeps { + contentManagement: ContentManagementPublicStart; dataViews: DataViewsPublicPluginStart; uiActions: UiActionsStart; } diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 394337e477ef93..1270c0802dff28 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, "include": [ "common/**/*", @@ -29,8 +29,8 @@ "@kbn/config-schema", "@kbn/storybook", "@kbn/ui-actions-plugin", + "@kbn/saved-objects-finder-plugin", + "@kbn/content-management-plugin" ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/test/functional/apps/dashboard/group3/copy_panel_to.ts b/test/functional/apps/dashboard/group3/copy_panel_to.ts index 81c5406426127d..dbafa5d68b5e8a 100644 --- a/test/functional/apps/dashboard/group3/copy_panel_to.ts +++ b/test/functional/apps/dashboard/group3/copy_panel_to.ts @@ -85,9 +85,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await label.click(); - await testSubjects.setValue('dashboardPickerInput', fewPanelsTitle); + await testSubjects.click('open-dashboard-picker'); + await testSubjects.setValue('dashboard-picker-search', fewPanelsTitle); await testSubjects.existOrFail(`dashboard-picker-option-few-panels`); - await find.clickByButtonText(fewPanelsTitle); + await testSubjects.click(`dashboard-picker-option-few-panels`); await testSubjects.click('confirmCopyToButton'); await PageObjects.dashboard.waitForRenderComplete(); @@ -113,7 +114,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await label.click(); - await testSubjects.setValue('dashboardPickerInput', fewPanelsTitle); + await testSubjects.click('open-dashboard-picker'); + await testSubjects.setValue('dashboard-picker-search', fewPanelsTitle); await testSubjects.missingOrFail(`dashboard-picker-option-few-panels`); await testSubjects.click('cancelCopyToButton'); diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 280a02354424c5..3b51e980290167 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -75,8 +75,11 @@ export class TimeToVisualizePageObject extends FtrService { await label.click(); if (dashboardId) { - await this.testSubjects.setValue('dashboardPickerInput', dashboardId); - await this.find.clickByButtonText(dashboardId); + await this.testSubjects.click('open-dashboard-picker'); + await this.testSubjects.setValue('dashboard-picker-search', dashboardId); + await this.testSubjects.click( + `dashboard-picker-option-${dashboardId.replaceAll(' ', '-')}` + ); } } diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/index.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/index.ts new file mode 100644 index 00000000000000..69a79adfab2e97 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { bulkDeleteRulesRequestBodySchema } from './schemas/latest'; +export { bulkDeleteRulesRequestBodySchema as bulkDeleteRulesRequestBodySchemaV1 } from './schemas/v1'; + +export type { + BulkDeleteRulesResponse, + BulkOperationError, + BulkDeleteRulesRequestBody, +} from './types/latest'; +export type { + BulkDeleteRulesResponse as BulkDeleteRulesResponseV1, + BulkOperationError as BulkOperationErrorV1, + BulkDeleteRulesRequestBody as BulkDeleteRulesRequestBodyV1, +} from './types/v1'; + +export { validateCommonBulkOptions } from './validation/latest'; +export { validateCommonBulkOptions as validateCommonBulkOptionsV1 } from './validation/v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/latest.ts new file mode 100644 index 00000000000000..25300c97a6d2e1 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/v1.ts new file mode 100644 index 00000000000000..7cf5da66650876 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/schemas/v1.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const bulkDeleteRulesRequestBodySchema = schema.object({ + filter: schema.maybe(schema.string()), + ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 })), +}); diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/latest.ts new file mode 100644 index 00000000000000..25300c97a6d2e1 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/v1.ts new file mode 100644 index 00000000000000..3b7f062b836aff --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/types/v1.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { TypeOf } from '@kbn/config-schema'; +import { bulkDeleteRulesRequestBodySchemaV1 } from '..'; +import { RuleParamsV1, RuleResponseV1 } from '../../../response'; + +export interface BulkOperationError { + message: string; + status?: number; + rule: { + id: string; + name: string; + }; +} + +export type BulkDeleteRulesRequestBody = TypeOf; + +export interface BulkDeleteRulesResponse { + body: { + rules: Array>; + errors: BulkOperationError[]; + total: number; + taskIdsFailedToBeDeleted: string[]; + }; +} diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/latest.ts new file mode 100644 index 00000000000000..25300c97a6d2e1 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/v1.ts new file mode 100644 index 00000000000000..e429d75db3e0cb --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_delete/validation/v1.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { BulkDeleteRulesRequestBody } from '..'; + +export const validateCommonBulkOptions = (options: BulkDeleteRulesRequestBody) => { + const filter = options.filter; + const ids = options.ids; + + if (!ids && !filter) { + throw Boom.badRequest( + "Either 'ids' or 'filter' property in method's arguments should be provided" + ); + } + + if (ids?.length === 0) { + throw Boom.badRequest("'ids' property should not be an empty array"); + } + + if (ids && filter) { + throw Boom.badRequest( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments" + ); + } +}; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts similarity index 84% rename from x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts rename to x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts index 8d478b12d65a9e..c33cd867a1be8b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts @@ -5,32 +5,33 @@ * 2.0. */ -import { RulesClient, ConstructorOptions } from '../rules_client'; +import { RulesClient, ConstructorOptions } from '../../../../rules_client/rules_client'; import { savedObjectsClientMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; -import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; -import { RecoveredActionGroup } from '../../../common'; +import { schema } from '@kbn/config-schema'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; -import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { getBeforeSetup, setGlobalDate } from './lib'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { loggerMock } from '@kbn/logging-mocks'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { RecoveredActionGroup } from '../../../../../common'; +import { AlertingAuthorization } from '../../../../authorization/alerting_authorization'; +import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/lib'; +import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { - defaultRule, - enabledRule1, - enabledRule2, - returnedRule1, - returnedRule2, - siemRule1, -} from './test_helpers'; -import { schema } from '@kbn/config-schema'; -import { migrateLegacyActions } from '../lib'; - -jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { + enabledRuleForBulkDelete1, + enabledRuleForBulkDelete2, + enabledRuleForBulkDelete3, + returnedRuleForBulkDelete1, + returnedRuleForBulkDelete2, + returnedRuleForBulkDelete3, + siemRuleForBulkDelete1, +} from '../../../../rules_client/tests/test_helpers'; +import { migrateLegacyActions } from '../../../../rules_client/lib'; + +jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { migrateLegacyActions: jest.fn(), }; @@ -41,7 +42,7 @@ jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { resultedReferences: [], }); -jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ +jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), })); @@ -91,36 +92,6 @@ const getBulkOperationStatusErrorResponse = (statusCode: number) => ({ }, }); -export const enabledRule3 = { - ...defaultRule, - id: 'id3', - attributes: { - ...defaultRule.attributes, - enabled: true, - scheduledTaskId: 'id3', - apiKey: Buffer.from('789:ghi').toString('base64'), - apiKeyCreatedByUser: true, - }, -}; - -export const returnedRule3 = { - actions: [], - alertTypeId: 'fakeType', - apiKey: 'Nzg5OmdoaQ==', - apiKeyCreatedByUser: true, - consumer: 'fakeConsumer', - enabled: true, - id: 'id3', - name: 'fakeName', - notifyWhen: undefined, - params: undefined, - schedule: { - interval: '5m', - }, - scheduledTaskId: 'id3', - snoozeSchedule: [], -}; - beforeEach(() => { getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); jest.clearAllMocks(); @@ -132,7 +103,13 @@ describe('bulkDelete', () => { let rulesClient: RulesClient; const mockCreatePointInTimeFinderAsInternalUser = ( - response = { saved_objects: [enabledRule1, enabledRule2, enabledRule3] } + response = { + saved_objects: [ + enabledRuleForBulkDelete1, + enabledRuleForBulkDelete2, + enabledRuleForBulkDelete3, + ], + } ) => { encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest .fn() @@ -197,11 +174,13 @@ describe('bulkDelete', () => { const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' }); expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith([ - enabledRule1, - enabledRule2, - enabledRule3, - ]); + expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith( + [enabledRuleForBulkDelete1, enabledRuleForBulkDelete2, enabledRuleForBulkDelete3].map( + ({ id }) => ({ id, type: 'alert' }) + ), + undefined + ); + expect(taskManager.bulkRemove).toHaveBeenCalledTimes(1); expect(taskManager.bulkRemove).toHaveBeenCalledWith(['id1', 'id3']); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); @@ -211,7 +190,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRule1, returnedRule3], + rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete3], errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 500 }], total: 2, taskIdsFailedToBeDeleted: [], @@ -241,25 +220,25 @@ describe('bulkDelete', () => { .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule1, enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete1, enabledRuleForBulkDelete2] }; }, }) .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete2] }; }, }) .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete2] }; }, }) .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete2] }; }, }); @@ -275,7 +254,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRule1], + rules: [returnedRuleForBulkDelete1], errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 409 }], total: 2, taskIdsFailedToBeDeleted: [], @@ -305,19 +284,19 @@ describe('bulkDelete', () => { .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule1, enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete1, enabledRuleForBulkDelete2] }; }, }) .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete2] }; }, }) .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule2] }; + yield { saved_objects: [enabledRuleForBulkDelete2] }; }, }); @@ -333,7 +312,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete2], errors: [], total: 2, taskIdsFailedToBeDeleted: [], @@ -481,15 +460,21 @@ describe('bulkDelete', () => { .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [enabledRule1, enabledRule2, siemRule1] }; + yield { + saved_objects: [ + enabledRuleForBulkDelete1, + enabledRuleForBulkDelete2, + siemRuleForBulkDelete1, + ], + }; }, }); unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({ statuses: [ - { id: enabledRule1.id, type: 'alert', success: true }, - { id: enabledRule2.id, type: 'alert', success: true }, - { id: siemRule1.id, type: 'alert', success: true }, + { id: enabledRuleForBulkDelete1.id, type: 'alert', success: true }, + { id: enabledRuleForBulkDelete2.id, type: 'alert', success: true }, + { id: siemRuleForBulkDelete1.id, type: 'alert', success: true }, ], }); @@ -497,19 +482,19 @@ describe('bulkDelete', () => { expect(migrateLegacyActions).toHaveBeenCalledTimes(3); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { - ruleId: enabledRule1.id, + ruleId: enabledRuleForBulkDelete1.id, skipActionsValidation: true, - attributes: enabledRule1.attributes, + attributes: enabledRuleForBulkDelete1.attributes, }); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { - ruleId: enabledRule2.id, + ruleId: enabledRuleForBulkDelete2.id, skipActionsValidation: true, - attributes: enabledRule2.attributes, + attributes: enabledRuleForBulkDelete2.attributes, }); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { - ruleId: siemRule1.id, + ruleId: siemRuleForBulkDelete1.id, skipActionsValidation: true, - attributes: siemRule1.attributes, + attributes: siemRuleForBulkDelete1.attributes, }); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_delete.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts similarity index 61% rename from x-pack/plugins/alerting/server/rules_client/methods/bulk_delete.ts rename to x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts index 03ce78952e9db8..e8a7b77a88c3d3 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_delete.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts @@ -5,30 +5,49 @@ * 2.0. */ import pMap from 'p-map'; +import Boom from '@hapi/boom'; import { KueryNode, nodeBuilder } from '@kbn/es-query'; import { SavedObjectsBulkUpdateObject } from '@kbn/core/server'; import { withSpan } from '@kbn/apm-utils'; -import { RawRule } from '../../types'; -import { convertRuleIdsToKueryNode } from '../../lib'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; -import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events'; -import { tryToRemoveTasks } from '../common'; -import { API_KEY_GENERATE_CONCURRENCY } from '../common/constants'; +import { convertRuleIdsToKueryNode } from '../../../../lib'; +import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; +import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; +import { tryToRemoveTasks } from '../../../../rules_client/common'; +import { API_KEY_GENERATE_CONCURRENCY } from '../../../../rules_client/common/constants'; import { getAuthorizationFilter, checkAuthorizationAndGetTotal, - getAlertFromRaw, migrateLegacyActions, -} from '../lib'; +} from '../../../../rules_client/lib'; import { retryIfBulkOperationConflicts, buildKueryNodeFilter, - getAndValidateCommonBulkOptions, -} from '../common'; -import { BulkOptions, BulkOperationError, RulesClientContext } from '../types'; +} from '../../../../rules_client/common'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import type { + BulkOperationError, + BulkDeleteRulesResult, + BulkDeleteRulesRequestBody, +} from './types'; +import { validateCommonBulkOptions } from './validation'; +import type { RuleAttributes } from '../../../../data/rule/types'; +import { bulkDeleteRulesSo } from '../../../../data/rule'; +import { transformRuleAttributesToRuleDomain, transformRuleDomainToRule } from '../../transforms'; +import { ruleDomainSchema } from '../../schemas'; +import type { RuleParams, RuleDomain } from '../../types'; +import type { RawRule, SanitizedRule } from '../../../../types'; + +export const bulkDeleteRules = async ( + context: RulesClientContext, + options: BulkDeleteRulesRequestBody +): Promise> => { + try { + validateCommonBulkOptions(options); + } catch (error) { + throw Boom.badRequest(`Error validating bulk delete data - ${error.message}`); + } -export const bulkDeleteRules = async (context: RulesClientContext, options: BulkOptions) => { - const { ids, filter } = getAndValidateCommonBulkOptions(options); + const { ids, filter } = options; const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter); const authorizationFilter = await getAuthorizationFilter(context, { action: 'DELETE' }); @@ -71,20 +90,35 @@ export const bulkDeleteRules = async (context: RulesClientContext, options: Bulk ]); const deletedRules = rules.map(({ id, attributes, references }) => { - return getAlertFromRaw( - context, + // TODO (http-versioning): alertTypeId should never be null, but we need to + // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject + // when we are doing the bulk create and this should fix itself + const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); + const ruleDomain = transformRuleAttributesToRuleDomain(attributes as RuleAttributes, { id, - attributes.alertTypeId as string, - attributes as RawRule, + logger: context.logger, + ruleType, references, - false - ); + omitGeneratedValues: false, + }); + + try { + ruleDomainSchema.validate(ruleDomain); + } catch (e) { + context.logger.warn(`Error validating bulk edited rule domain object for id: ${id}, ${e}`); + } + return ruleDomain; }); + // // TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed + const deletedPublicRules = deletedRules.map((rule: RuleDomain) => { + return transformRuleDomainToRule(rule); + }) as Array>; + if (result.status === 'fulfilled') { - return { errors, total, rules: deletedRules, taskIdsFailedToBeDeleted: result.value }; + return { errors, total, rules: deletedPublicRules, taskIdsFailedToBeDeleted: result.value }; } else { - return { errors, total, rules: deletedRules, taskIdsFailedToBeDeleted: [] }; + return { errors, total, rules: deletedPublicRules, taskIdsFailedToBeDeleted: [] }; } }; @@ -98,15 +132,17 @@ const bulkDeleteWithOCC = async ( type: 'rules', }, () => - context.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({ - filter, - type: 'alert', - perPage: 100, - ...(context.namespace ? { namespaces: [context.namespace] } : undefined), - }) + context.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( + { + filter, + type: 'alert', + perPage: 100, + ...(context.namespace ? { namespaces: [context.namespace] } : undefined), + } + ) ); - const rulesToDelete: Array> = []; + const rulesToDelete: Array> = []; const apiKeyToRuleIdMapping: Record = {}; const taskIdToRuleIdMapping: Record = {}; const ruleNameToRuleIdMapping: Record = {}; @@ -142,7 +178,11 @@ const bulkDeleteWithOCC = async ( const result = await withSpan( { name: 'unsecuredSavedObjectsClient.bulkDelete', type: 'rules' }, - () => context.unsecuredSavedObjectsClient.bulkDelete(rulesToDelete) + () => + bulkDeleteRulesSo({ + savedObjectsClient: context.unsecuredSavedObjectsClient, + ids: rulesToDelete.map(({ id }) => id), + }) ); const deletedRuleIds: string[] = []; @@ -173,6 +213,7 @@ const bulkDeleteWithOCC = async ( const rules = rulesToDelete.filter((rule) => deletedRuleIds.includes(rule.id)); // migrate legacy actions only for SIEM rules + // TODO (http-versioning) Remove RawRuleAction and RawRule casts await pMap( rules, async (rule) => { diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/index.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/index.ts new file mode 100644 index 00000000000000..ac9048c366b6b3 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { bulkDeleteRules } from './bulk_delete_rules'; +export type { BulkDeleteRulesResult, BulkDeleteRulesRequestBody } from './types'; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/schemas/index.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/schemas/index.ts new file mode 100644 index 00000000000000..7cf5da66650876 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/schemas/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const bulkDeleteRulesRequestBodySchema = schema.object({ + filter: schema.maybe(schema.string()), + ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 })), +}); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/types/index.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/types/index.ts new file mode 100644 index 00000000000000..d7075638a50270 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/types/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { TypeOf } from '@kbn/config-schema'; +import { bulkDeleteRulesRequestBodySchema } from '../schemas'; +import type { SanitizedRule } from '../../../../../types'; +import type { RuleParams } from '../../../types'; + +export interface BulkOperationError { + message: string; + status?: number; + rule: { + id: string; + name: string; + }; +} + +export type BulkDeleteRulesRequestBody = TypeOf; + +export interface BulkDeleteRulesResult { + rules: Array>; + errors: BulkOperationError[]; + total: number; + taskIdsFailedToBeDeleted: string[]; +} diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/validation/index.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/validation/index.ts new file mode 100644 index 00000000000000..35893977bb06cd --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/validation/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { BulkDeleteRulesRequestBody } from '../types'; + +export const validateCommonBulkOptions = (options: BulkDeleteRulesRequestBody) => { + const filter = options.filter; + const ids = options.ids; + + if (!ids && !filter) { + throw Boom.badRequest( + "Either 'ids' or 'filter' property in method's arguments should be provided" + ); + } + + if (ids?.length === 0) { + throw Boom.badRequest("'ids' property should not be an empty array"); + } + + if (ids && filter) { + throw Boom.badRequest( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments" + ); + } +}; diff --git a/x-pack/plugins/alerting/server/data/rule/index.ts b/x-pack/plugins/alerting/server/data/rule/index.ts index 6256ac9b2278a0..08306a281ac52c 100644 --- a/x-pack/plugins/alerting/server/data/rule/index.ts +++ b/x-pack/plugins/alerting/server/data/rule/index.ts @@ -15,3 +15,5 @@ export { findRulesSo } from './methods/find_rules_so'; export type { FindRulesSoParams } from './methods/find_rules_so'; export { bulkCreateRulesSo } from './methods/bulk_create_rule_so'; export type { BulkCreateRulesSoParams } from './methods/bulk_create_rule_so'; +export { bulkDeleteRulesSo } from './methods/bulk_delete_rules_so'; +export type { BulkDeleteRulesSoParams } from './methods/bulk_delete_rules_so'; diff --git a/x-pack/plugins/alerting/server/data/rule/methods/bulk_delete_rules_so.ts b/x-pack/plugins/alerting/server/data/rule/methods/bulk_delete_rules_so.ts new file mode 100644 index 00000000000000..7f3568f76cf98e --- /dev/null +++ b/x-pack/plugins/alerting/server/data/rule/methods/bulk_delete_rules_so.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectsClientContract, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, +} from '@kbn/core/server'; + +export interface BulkDeleteRulesSoParams { + savedObjectsClient: SavedObjectsClientContract; + ids: string[]; + savedObjectsBulkDeleteOptions?: SavedObjectsBulkDeleteOptions; +} + +export const bulkDeleteRulesSo = ( + params: BulkDeleteRulesSoParams +): Promise => { + const { savedObjectsClient, ids, savedObjectsBulkDeleteOptions } = params; + + return savedObjectsClient.bulkDelete( + ids.map((id) => ({ id, type: 'alert' })), + savedObjectsBulkDeleteOptions + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/bulk_delete_rules.ts b/x-pack/plugins/alerting/server/routes/bulk_delete_rules.ts deleted file mode 100644 index 1eca663de21a81..00000000000000 --- a/x-pack/plugins/alerting/server/routes/bulk_delete_rules.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { IRouter } from '@kbn/core/server'; -import { verifyAccessAndContext, handleDisabledApiKeysError } from './lib'; -import { ILicenseState, RuleTypeDisabledError } from '../lib'; -import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; - -export const bulkDeleteRulesRoute = ({ - router, - licenseState, -}: { - router: IRouter; - licenseState: ILicenseState; -}) => { - router.patch( - { - path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_delete`, - validate: { - body: schema.object({ - filter: schema.maybe(schema.string()), - ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 })), - }), - }, - }, - handleDisabledApiKeysError( - router.handleLegacyErrors( - verifyAccessAndContext(licenseState, async (context, req, res) => { - const rulesClient = (await context.alerting).getRulesClient(); - const { filter, ids } = req.body; - - try { - const result = await rulesClient.bulkDeleteRules({ filter, ids }); - return res.ok({ body: result }); - } catch (e) { - if (e instanceof RuleTypeDisabledError) { - return e.sendResponse(res); - } - throw e; - } - }) - ) - ) - ); -}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index fe82abe56c4aff..a93803ed6d585c 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -40,7 +40,7 @@ import { bulkEditInternalRulesRoute } from './rule/apis/bulk_edit/bulk_edit_rule import { snoozeRuleRoute } from './snooze_rule'; import { unsnoozeRuleRoute } from './unsnooze_rule'; import { runSoonRoute } from './run_soon'; -import { bulkDeleteRulesRoute } from './bulk_delete_rules'; +import { bulkDeleteRulesRoute } from './rule/apis/bulk_delete/bulk_delete_rules_route'; import { bulkEnableRulesRoute } from './bulk_enable_rules'; import { bulkDisableRulesRoute } from './bulk_disable_rules'; import { cloneRuleRoute } from './clone_rule'; diff --git a/x-pack/plugins/alerting/server/routes/bulk_delete_rules.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts similarity index 87% rename from x-pack/plugins/alerting/server/routes/bulk_delete_rules.test.ts rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts index c15405be852533..21099f6ba60865 100644 --- a/x-pack/plugins/alerting/server/routes/bulk_delete_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts @@ -7,16 +7,16 @@ import { httpServiceMock } from '@kbn/core/server/mocks'; -import { bulkDeleteRulesRoute } from './bulk_delete_rules'; -import { licenseStateMock } from '../lib/license_state.mock'; -import { mockHandlerArguments } from './_mock_handler_arguments'; -import { rulesClientMock } from '../rules_client.mock'; -import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; -import { verifyApiAccess } from '../lib/license_api_access'; +import { bulkDeleteRulesRoute } from './bulk_delete_rules_route'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; const rulesClient = rulesClientMock.create(); -jest.mock('../lib/license_api_access', () => ({ +jest.mock('../../../../lib/license_api_access', () => ({ verifyApiAccess: jest.fn(), })); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.ts new file mode 100644 index 00000000000000..24dff471319780 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.ts @@ -0,0 +1,69 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { verifyAccessAndContext, handleDisabledApiKeysError } from '../../../lib'; +import { ILicenseState, RuleTypeDisabledError } from '../../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { + bulkDeleteRulesRequestBodySchemaV1, + BulkDeleteRulesRequestBodyV1, + BulkDeleteRulesResponseV1, +} from '../../../../../common/routes/rule/apis/bulk_delete'; +import type { RuleParamsV1 } from '../../../../../common/routes/rule/response'; +import { transformRuleToRuleResponseV1 } from '../../transforms'; +import { Rule } from '../../../../application/rule/types'; + +export const bulkDeleteRulesRoute = ({ + router, + licenseState, +}: { + router: IRouter; + licenseState: ILicenseState; +}) => { + router.patch( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_delete`, + validate: { + body: bulkDeleteRulesRequestBodySchemaV1, + }, + }, + handleDisabledApiKeysError( + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async (context, req, res) => { + const rulesClient = (await context.alerting).getRulesClient(); + + const body: BulkDeleteRulesRequestBodyV1 = req.body; + const { filter, ids } = body; + + try { + const bulkDeleteResults = await rulesClient.bulkDeleteRules({ + filter, + ids, + }); + const resultBody: BulkDeleteRulesResponseV1 = { + body: { + ...bulkDeleteResults, + rules: bulkDeleteResults.rules.map((rule) => { + // TODO (http-versioning): Remove this cast, this enables us to move forward + // without fixing all of other solution types + return transformRuleToRuleResponseV1(rule as Rule); + }), + }, + }; + return res.ok(resultBody); + } catch (e) { + if (e instanceof RuleTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } + }) + ) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_client/common/retry_if_bulk_operation_conflicts.ts b/x-pack/plugins/alerting/server/rules_client/common/retry_if_bulk_operation_conflicts.ts index 428f43a0dcfa64..7a652c5230f47c 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/retry_if_bulk_operation_conflicts.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/retry_if_bulk_operation_conflicts.ts @@ -13,13 +13,13 @@ import { withSpan } from '@kbn/apm-utils'; import { convertRuleIdsToKueryNode } from '../../lib'; import { BulkOperationError } from '../types'; import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry'; -import { RawRule } from '../../types'; +import { RuleAttributes } from '../../data/rule/types'; const MAX_RULES_IDS_IN_RETRY = 1000; interface BulkOperationResult { errors: BulkOperationError[]; - rules: Array>; + rules: Array>; accListSpecificForBulkOperation: string[][]; } @@ -63,7 +63,7 @@ const handler = async ({ filter: KueryNode | null; accListSpecificForBulkOperation?: string[][]; accErrors?: BulkOperationError[]; - accRules?: Array>; + accRules?: Array>; retries?: number; }): Promise => { try { diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_disable.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_disable.ts index 5ec0ae99ca99c1..30b8b1eb01bba4 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_disable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_disable.ts @@ -28,6 +28,7 @@ import { } from '../lib'; import { BulkOptions, BulkOperationError, RulesClientContext } from '../types'; import { tryToRemoveTasks } from '../common'; +import { RuleAttributes } from '../../data/rule/types'; export const bulkDisableRules = async (context: RulesClientContext, options: BulkOptions) => { const { ids, filter } = getAndValidateCommonBulkOptions(options); @@ -218,7 +219,8 @@ const bulkDisableRulesWithOCC = async ( return { errors, - rules: disabledRules, + // TODO: delete the casting when we do versioning of bulk disable api + rules: disabledRules as Array>, accListSpecificForBulkOperation: [taskIdsToDisable, taskIdsToDelete], }; }; diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts index 0e3f0eb042ec5a..fda778e6b11af1 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts @@ -30,6 +30,7 @@ import { } from '../lib'; import { RulesClientContext, BulkOperationError, BulkOptions } from '../types'; import { validateScheduleLimit } from '../../application/rule/methods/get_schedule_frequency'; +import { RuleAttributes } from '../../data/rule/types'; const getShouldScheduleTask = async ( context: RulesClientContext, @@ -284,7 +285,12 @@ const bulkEnableRulesWithOCC = async ( }); } }); - return { errors, rules, accListSpecificForBulkOperation: [taskIdsToEnable] }; + return { + errors, + // TODO: delete the casting when we do versioning of bulk disable api + rules: rules as Array>, + accListSpecificForBulkOperation: [taskIdsToEnable], + }; }; export const tryToEnableTasks = async ({ diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index f2274648d39816..4c1138f23cb39d 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -8,7 +8,6 @@ import { SanitizedRule, RuleTypeParams } from '../types'; import { parseDuration } from '../../common/parse_duration'; import { RulesClientContext, BulkOptions, MuteOptions } from './types'; - import { clone, CloneArguments } from './methods/clone'; import { createRule, CreateRuleParams } from '../application/rule/methods/create'; import { get, GetParams } from './methods/get'; @@ -37,7 +36,10 @@ import { AggregateParams } from '../application/rule/methods/aggregate/types'; import { aggregateRules } from '../application/rule/methods/aggregate'; import { deleteRule } from './methods/delete'; import { update, UpdateOptions } from './methods/update'; -import { bulkDeleteRules } from './methods/bulk_delete'; +import { + bulkDeleteRules, + BulkDeleteRulesRequestBody, +} from '../application/rule/methods/bulk_delete'; import { bulkEditRules, BulkEditOptions, @@ -139,7 +141,8 @@ export class RulesClient { public getActionErrorLogWithAuth = (params: GetActionErrorLogByIdParams) => getActionErrorLogWithAuth(this.context, params); - public bulkDeleteRules = (options: BulkOptions) => bulkDeleteRules(this.context, options); + public bulkDeleteRules = (options: BulkDeleteRulesRequestBody) => + bulkDeleteRules(this.context, options); public bulkEdit = (options: BulkEditOptions) => bulkEditRules(this.context, options); public bulkEnableRules = (options: BulkOptions) => bulkEnableRules(this.context, options); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts b/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts index fd4e5348389403..ac8f482e88c5b5 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts @@ -42,6 +42,24 @@ export const defaultRule = { version: '1', }; +export const defaultRuleForBulkDelete = { + id: 'id1', + type: 'alert', + attributes: { + name: 'fakeName', + consumer: 'fakeConsumer', + alertTypeId: 'fakeType', + schedule: { interval: '5m' }, + actions: [] as unknown, + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), + status: 'pending', + }, + }, + references: [], + version: '1', +}; + export const siemRule1 = { ...defaultRule, attributes: { @@ -51,6 +69,15 @@ export const siemRule1 = { id: 'siem-id1', }; +export const siemRuleForBulkDelete1 = { + ...defaultRuleForBulkDelete, + attributes: { + ...defaultRuleForBulkDelete.attributes, + consumer: AlertConsumers.SIEM, + }, + id: 'siem-id1', +}; + export const siemRule2 = { ...siemRule1, id: 'siem-id2', @@ -77,6 +104,51 @@ export const enabledRule2 = { }, }; +export const enabledRule3 = { + ...defaultRule, + id: 'id3', + attributes: { + ...defaultRule.attributes, + enabled: true, + scheduledTaskId: 'id3', + apiKey: Buffer.from('789:ghi').toString('base64'), + apiKeyCreatedByUser: true, + }, +}; + +export const enabledRuleForBulkDelete1 = { + ...defaultRuleForBulkDelete, + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: true, + scheduledTaskId: 'id1', + apiKey: Buffer.from('123:abc').toString('base64'), + }, +}; + +export const enabledRuleForBulkDelete2 = { + ...defaultRuleForBulkDelete, + id: 'id2', + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: true, + scheduledTaskId: 'id2', + apiKey: Buffer.from('321:abc').toString('base64'), + }, +}; + +export const enabledRuleForBulkDelete3 = { + ...defaultRuleForBulkDelete, + id: 'id3', + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: true, + scheduledTaskId: 'id3', + apiKey: Buffer.from('789:ghi').toString('base64'), + apiKeyCreatedByUser: true, + }, +}; + export const disabledRule1 = { ...defaultRule, attributes: { @@ -166,6 +238,67 @@ export const returnedRule2 = { snoozeSchedule: [], }; +export const returnedRuleForBulkDelete1 = { + actions: [], + alertTypeId: 'fakeType', + consumer: 'fakeConsumer', + enabled: true, + id: 'id1', + name: 'fakeName', + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), + status: 'pending', + }, + createdAt: new Date('2019-02-12T21:01:22.479Z'), + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + schedule: { + interval: '5m', + }, + scheduledTaskId: 'id1', + snoozeSchedule: [], +}; + +export const returnedRuleForBulkDelete2 = { + actions: [], + alertTypeId: 'fakeType', + consumer: 'fakeConsumer', + enabled: true, + id: 'id2', + name: 'fakeName', + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), + status: 'pending', + }, + createdAt: new Date('2019-02-12T21:01:22.479Z'), + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + schedule: { + interval: '5m', + }, + scheduledTaskId: 'id2', + snoozeSchedule: [], +}; + +export const returnedRuleForBulkDelete3 = { + actions: [], + alertTypeId: 'fakeType', + apiKeyCreatedByUser: true, + consumer: 'fakeConsumer', + enabled: true, + id: 'id3', + name: 'fakeName', + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), + status: 'pending', + }, + createdAt: new Date('2019-02-12T21:01:22.479Z'), + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + schedule: { + interval: '5m', + }, + scheduledTaskId: 'id3', + snoozeSchedule: [], +}; + export const returnedDisabledRule1 = { ...returnedRule1, enabled: false, diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/stats/stats.tsx b/x-pack/plugins/apm/public/components/app/mobile/service_overview/stats/stats.tsx index 3e8d42d5a2a3c4..a0caf4b3002ac8 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/service_overview/stats/stats.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/stats/stats.tsx @@ -110,10 +110,12 @@ export function MobileStats({ defaultMessage: 'Crash rate', }), icon: getIcon('bug'), - value: data?.currentPeriod?.crashRate?.value ?? NOT_AVAILABLE_LABEL, + value: data?.currentPeriod?.crashRate?.value ?? NaN, valueFormatter: (value: number) => - valueFormatter(Number((value * 100).toPrecision(2)), '%'), - trend: data?.currentPeriod?.crashRate?.timeseries, + Number.isNaN(value) + ? NOT_AVAILABLE_LABEL + : valueFormatter(Number((value * 100).toPrecision(2)), '%'), + trend: data?.currentPeriod?.crashRate?.timeseries ?? [], extra: getComparisonValueFormatter(data?.previousPeriod.crashRate?.value), trendShape: MetricTrendShape.Area, }, @@ -137,8 +139,9 @@ export function MobileStats({ defaultMessage: 'Sessions', }), icon: getIcon('timeslider'), - value: data?.currentPeriod?.sessions?.value ?? NOT_AVAILABLE_LABEL, - valueFormatter: (value: number) => valueFormatter(value), + value: data?.currentPeriod?.sessions?.value ?? NaN, + valueFormatter: (value: number) => + Number.isNaN(value) ? NOT_AVAILABLE_LABEL : valueFormatter(value), trend: data?.currentPeriod?.sessions?.timeseries, extra: getComparisonValueFormatter(data?.previousPeriod.sessions?.value), trendShape: MetricTrendShape.Area, @@ -149,10 +152,11 @@ export function MobileStats({ defaultMessage: 'HTTP requests', }), icon: getIcon('kubernetesPod'), - value: data?.currentPeriod?.requests?.value ?? NOT_AVAILABLE_LABEL, extra: getComparisonValueFormatter(data?.previousPeriod.requests?.value), - valueFormatter: (value: number) => valueFormatter(value), - trend: data?.currentPeriod?.requests?.timeseries, + value: data?.currentPeriod?.requests?.value ?? NaN, + valueFormatter: (value: number) => + Number.isNaN(value) ? NOT_AVAILABLE_LABEL : valueFormatter(value), + trend: data?.currentPeriod?.requests?.timeseries ?? [], trendShape: MetricTrendShape.Area, }, ]; diff --git a/x-pack/plugins/apm/public/components/app/storage_explorer/summary_stats.tsx b/x-pack/plugins/apm/public/components/app/storage_explorer/summary_stats.tsx index 57a348d4839d48..4ed48894c247a4 100644 --- a/x-pack/plugins/apm/public/components/app/storage_explorer/summary_stats.tsx +++ b/x-pack/plugins/apm/public/components/app/storage_explorer/summary_stats.tsx @@ -205,7 +205,7 @@ function SummaryMetric({ loading: boolean; hasData: boolean; }) { - const xlFontSize = useEuiFontSize('xl', { measurement: 'px' }); + const xlFontSize = useEuiFontSize('xl', { unit: 'px' }); const { euiTheme } = useEuiTheme(); return ( diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts index 803a69bd44e210..df066a57418fcd 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts @@ -25,7 +25,7 @@ const apmRouter = { } as ApmRouter; const infraLocators = infraLocatorsMock; -const observabilityLogExplorerLocators = observabilityLogExplorerLocatorsMock; +const { allDatasetsLocator } = observabilityLogExplorerLocatorsMock; const expectInfraLocatorsToBeCalled = () => { expect(infraLocators.nodeLogsLocator.getRedirectUrl).toBeCalledTimes(3); @@ -65,7 +65,7 @@ describe('Transaction action menu', () => { location, apmRouter, infraLocators, - observabilityLogExplorerLocators, + allDatasetsLocator, infraLinksAvailable: false, rangeFrom: 'now-24h', rangeTo: 'now', @@ -131,7 +131,7 @@ describe('Transaction action menu', () => { location, apmRouter, infraLocators, - observabilityLogExplorerLocators, + allDatasetsLocator, infraLinksAvailable: true, rangeFrom: 'now-24h', rangeTo: 'now', @@ -216,7 +216,7 @@ describe('Transaction action menu', () => { location, apmRouter, infraLocators, - observabilityLogExplorerLocators, + allDatasetsLocator, infraLinksAvailable: true, rangeFrom: 'now-24h', rangeTo: 'now', diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 941e8568a45b70..e135161129c7c3 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -132,6 +132,11 @@ export const infraLocatorsMock: InfraLocators = { nodeLogsLocator: sharePluginMock.createLocator(), }; +export const observabilityLogExplorerLocatorsMock = { + allDatasetsLocator: sharePluginMock.createLocator(), + singleDatasetLocator: sharePluginMock.createLocator(), +}; + const mockCorePlugins = { embeddable: {}, inspector: {}, diff --git a/x-pack/plugins/apm/server/routes/assistant_functions/route.ts b/x-pack/plugins/apm/server/routes/assistant_functions/route.ts index 436f514e423ff0..0d86bff4f7406c 100644 --- a/x-pack/plugins/apm/server/routes/assistant_functions/route.ts +++ b/x-pack/plugins/apm/server/routes/assistant_functions/route.ts @@ -191,7 +191,7 @@ const getApmErrorDocRoute = createApmServerRoute({ }, }); -interface ApmServicesListItem { +export interface ApmServicesListItem { 'service.name': string; 'agent.name'?: string; 'transaction.type'?: string; diff --git a/x-pack/plugins/apm/server/routes/traces/queries.test.ts b/x-pack/plugins/apm/server/routes/traces/queries.test.ts index 03783b1f778742..18367b6745d849 100644 --- a/x-pack/plugins/apm/server/routes/traces/queries.test.ts +++ b/x-pack/plugins/apm/server/routes/traces/queries.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { loggerMock } from '@kbn/logging-mocks'; import { getTraceItems } from './get_trace_items'; import { SearchParamsMock, @@ -26,6 +27,7 @@ describe('trace queries', () => { apmEventClient: mockApmEventClient, start: 0, end: 50000, + logger: loggerMock.create(), }) ); diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index 905b08ed49f609..34e24033db2304 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -18,7 +18,7 @@ import { TimestampUs } from './fields/timestamp_us'; import { Url } from './fields/url'; import { User } from './fields/user'; -interface Processor { +export interface Processor { name: 'error'; event: 'error'; } diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template_api/get_csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template_api/get_csp_rule_template.ts index 7b6345e749d71d..350909e540d4dd 100644 --- a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template_api/get_csp_rule_template.ts +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template_api/get_csp_rule_template.ts @@ -57,7 +57,7 @@ export const findCspRuleTemplateRequest = schema.object({ schema.literal('metadata.benchmark.rule_number'), ], { - defaultValue: 'metadata.name', + defaultValue: 'metadata.benchmark.rule_number', } ), @@ -79,4 +79,9 @@ export const findCspRuleTemplateRequest = schema.object({ * package_policy_id */ packagePolicyId: schema.maybe(schema.string()), + + /** + * rule section + */ + section: schema.maybe(schema.string()), }); diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index f2b6c00399915b..036826ba9e6de0 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -122,6 +122,7 @@ export interface Benchmark { export type BenchmarkId = CspRuleTemplateMetadata['benchmark']['id']; export type BenchmarkName = CspRuleTemplateMetadata['benchmark']['name']; +export type RuleSection = CspRuleTemplateMetadata['section']; // Fleet Integration types export type PostureInput = typeof SUPPORTED_CLOUDBEAT_INPUTS[number]; diff --git a/x-pack/plugins/cloud_security_posture/common/utils/helpers.test.ts b/x-pack/plugins/cloud_security_posture/common/utils/helpers.test.ts index 7e7d25a5b28bd8..6ce2add754f200 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/helpers.test.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/helpers.test.ts @@ -6,11 +6,7 @@ */ import { createPackagePolicyMock } from '@kbn/fleet-plugin/common/mocks'; -import { - getBenchmarkFromPackagePolicy, - getBenchmarkTypeFilter, - cleanupCredentials, -} from './helpers'; +import { getBenchmarkFromPackagePolicy, getBenchmarkFilter, cleanupCredentials } from './helpers'; describe('test helper methods', () => { it('get default integration type from inputs with multiple enabled types', () => { @@ -60,11 +56,20 @@ describe('test helper methods', () => { const typeK8s = getBenchmarkFromPackagePolicy(mockPackagePolicy.inputs); expect(typeK8s).toMatch('cis_k8s'); }); + it('get benchmark type filter based on a benchmark id', () => { - const typeFilter = getBenchmarkTypeFilter('cis_eks'); + const typeFilter = getBenchmarkFilter('cis_eks'); expect(typeFilter).toMatch('csp-rule-template.attributes.metadata.benchmark.id: "cis_eks"'); }); + it('should return a string with the correct filter when given a benchmark type and section', () => { + const typeAndSectionFilter = getBenchmarkFilter('cis_k8s', 'API Server'); + + expect(typeAndSectionFilter).toMatch( + 'csp-rule-template.attributes.metadata.benchmark.id: "cis_k8s" AND csp-rule-template.attributes.metadata.section: "API Server"' + ); + }); + describe('cleanupCredentials', () => { it('cleans unused aws credential methods, except role_arn when using assume_role', () => { const mockPackagePolicy = createPackagePolicyMock(); diff --git a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts index 9cf31c944d27ae..1cf006589bd7a0 100644 --- a/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts +++ b/x-pack/plugins/cloud_security_posture/common/utils/helpers.ts @@ -27,6 +27,7 @@ import type { BaseCspSetupStatus, AwsCredentialsType, GcpCredentialsType, + RuleSection, } from '../types'; /** @@ -46,8 +47,12 @@ export const extractErrorMessage = (e: unknown, defaultMessage = 'Unknown Error' return defaultMessage; // TODO: i18n }; -export const getBenchmarkTypeFilter = (type: BenchmarkId): string => - `${CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE}.attributes.metadata.benchmark.id: "${type}"`; +export const getBenchmarkFilter = (type: BenchmarkId, section?: RuleSection): string => + `${CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE}.attributes.metadata.benchmark.id: "${type}"${ + section + ? ` AND ${CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE}.attributes.metadata.section: "${section}"` + : '' + }`; export const isEnabledBenchmarkInputType = (input: PackagePolicyInput | NewPackagePolicyInput) => input.enabled; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx index 74ce3ba55ef85b..81472e29b6ee77 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx @@ -58,6 +58,7 @@ export const RulesContainer = () => { const [selectedRuleId, setSelectedRuleId] = useState(null); const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE_RULES_KEY); const [rulesQuery, setRulesQuery] = useState({ + section: undefined, search: '', page: 0, perPage: pageSize || 10, @@ -65,6 +66,7 @@ export const RulesContainer = () => { const { data, status, error } = useFindCspRuleTemplates( { + section: rulesQuery.section, search: rulesQuery.search, page: 1, perPage: MAX_ITEMS_PER_PAGE, @@ -77,12 +79,31 @@ export const RulesContainer = () => { [data, error, status, rulesQuery] ); + // We need to make this call again without the filters. this way the section list is always full + const allRules = useFindCspRuleTemplates( + { + page: 1, + perPage: MAX_ITEMS_PER_PAGE, + }, + params.packagePolicyId + ); + + const sectionList = useMemo( + () => allRules.data?.items.map((rule) => rule.metadata.section), + [allRules.data] + ); + const cleanedSectionList = [...new Set(sectionList)]; + return (
+ setRulesQuery((currentQuery) => ({ ...currentQuery, section: value })) + } + sectionSelectOptions={cleanedSectionList} search={(value) => setRulesQuery((currentQuery) => ({ ...currentQuery, search: value }))} - searchValue={rulesQuery.search} + searchValue={rulesQuery.search || ''} totalRulesCount={rulesPageData.all_rules.length} pageSize={rulesPageData.rules_page.length} isSearching={status === 'loading'} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx index 557239e5cb6132..167589024529bc 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table.tsx @@ -58,12 +58,6 @@ export const RulesTable = ({ style: { background: row.metadata.id === selectedRuleId ? euiTheme.colors.highlight : undefined, }, - onClick: (e: MouseEvent) => { - const tag = (e.target as HTMLDivElement).tagName; - // Ignore checkbox and switch toggle columns - if (tag === 'BUTTON' || tag === 'INPUT') return; - setSelectedRuleId(row.metadata.id); - }, }); return ( @@ -86,6 +80,13 @@ type GetColumnProps = Pick; const getColumns = ({ setSelectedRuleId, }: GetColumnProps): Array> => [ + { + field: 'metadata.benchmark.rule_number', + name: i18n.translate('xpack.csp.rules.rulesTable.ruleNumberColumnLabel', { + defaultMessage: 'Rule Number', + }), + width: '10%', + }, { field: 'metadata.name', name: i18n.translate('xpack.csp.rules.rulesTable.nameColumnLabel', { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx index 0109915ff68c6e..18e31bdb366dbb 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -5,13 +5,24 @@ * 2.0. */ import React, { useState } from 'react'; -import { EuiFieldSearch, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import { + EuiComboBox, + EuiFieldSearch, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiFlexGroup, + type EuiComboBoxOptionOption, +} from '@elastic/eui'; import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; interface RulesTableToolbarProps { - search(value: string): void; + search: (value: string) => void; + onSectionChange: (value: string | undefined) => void; + sectionSelectOptions: string[]; totalRulesCount: number; searchValue: string; isSearching: boolean; @@ -29,15 +40,47 @@ export const RulesTableHeader = ({ isSearching, totalRulesCount, pageSize, -}: RulesTableToolbarProps) => ( - -); + onSectionChange, + sectionSelectOptions, +}: RulesTableToolbarProps) => { + const [selected, setSelected] = useState([]); + + const sectionOptions = sectionSelectOptions.map((option) => ({ + label: option, + })); + + return ( + + + + + + { + setSelected(option); + onSectionChange(option.length ? option[0].label : undefined); + }} + /> + + + ); +}; const SEARCH_DEBOUNCE_MS = 300; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts index e0870d1e64b018..ac150e05bf95ec 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts @@ -14,20 +14,20 @@ import { FIND_CSP_RULE_TEMPLATE_ROUTE_PATH, } from '../../../common/constants'; -export type RulesQuery = Required>; +export type RulesQuery = Pick; export type RulesQueryResult = ReturnType; export const useFindCspRuleTemplates = ( - { search, page, perPage }: RulesQuery, + { search, page, perPage, section }: RulesQuery, packagePolicyId: string ) => { const { http } = useKibana().services; return useQuery( - [CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE, { search, page, perPage, packagePolicyId }], + [CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE, { section, search, page, perPage, packagePolicyId }], () => { return http.get(FIND_CSP_RULE_TEMPLATE_ROUTE_PATH, { - query: { packagePolicyId, page, perPage, search }, + query: { packagePolicyId, page, perPage, search, section }, version: FIND_CSP_RULE_TEMPLATE_API_CURRENT_VERSION, }); } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index a62e4a1c4ee09a..4c78265c1c15ee 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -18,7 +18,7 @@ import { benchmarksQueryParamsSchema } from '../../../common/schemas/benchmark'; import type { Benchmark } from '../../../common/types'; import { getBenchmarkFromPackagePolicy, - getBenchmarkTypeFilter, + getBenchmarkFilter, isNonNullable, } from '../../../common/utils/helpers'; import { CspRouter } from '../../types'; @@ -38,7 +38,7 @@ export const getRulesCountForPolicy = async ( ): Promise => { const rules = await soClient.find({ type: CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE, - filter: getBenchmarkTypeFilter(benchmarkId), + filter: getBenchmarkFilter(benchmarkId), perPage: 0, }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_template.ts index fb6e0e7c53ab0a..0b249e9f3656ef 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_template.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_template.ts @@ -8,13 +8,12 @@ import { NewPackagePolicy } from '@kbn/fleet-plugin/common'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { transformError } from '@kbn/securitysolution-es-utils'; +import semverCompare from 'semver/functions/compare'; +import semverValid from 'semver/functions/valid'; import { GetCspRuleTemplateRequest, GetCspRuleTemplateResponse } from '../../../common/types'; import { CspRuleTemplate } from '../../../common/schemas'; import { findCspRuleTemplateRequest } from '../../../common/schemas/csp_rule_template_api/get_csp_rule_template'; -import { - getBenchmarkFromPackagePolicy, - getBenchmarkTypeFilter, -} from '../../../common/utils/helpers'; +import { getBenchmarkFromPackagePolicy, getBenchmarkFilter } from '../../../common/utils/helpers'; import { CSP_RULE_TEMPLATE_SAVED_OBJECT_TYPE, @@ -23,6 +22,22 @@ import { import { CspRouter } from '../../types'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../benchmarks/benchmarks'; +export const getSortedCspRulesTemplates = (cspRulesTemplates: CspRuleTemplate[]) => { + return cspRulesTemplates.slice().sort((a, b) => { + const ruleNumberA = a?.metadata?.benchmark?.rule_number; + const ruleNumberB = b?.metadata?.benchmark?.rule_number; + + const versionA = semverValid(ruleNumberA); + const versionB = semverValid(ruleNumberB); + + if (versionA !== null && versionB !== null) { + return semverCompare(versionA, versionB); + } else { + return String(ruleNumberA).localeCompare(String(ruleNumberB)); + } + }); +}; + const getBenchmarkIdFromPackagePolicyId = async ( soClient: SavedObjectsClientContract, packagePolicyId: string @@ -57,15 +72,18 @@ const findCspRuleTemplateHandler = async ( perPage: options.perPage, sortField: options.sortField, fields: options?.fields, - filter: getBenchmarkTypeFilter(benchmarkId), + filter: getBenchmarkFilter(benchmarkId, options.section), }); const cspRulesTemplates = cspRulesTemplatesSo.saved_objects.map( (cspRuleTemplate) => cspRuleTemplate.attributes ); + // Semantic version sorting using semver for valid versions and custom comparison for invalid versions + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + return { - items: cspRulesTemplates, + items: sortedCspRulesTemplates, total: cspRulesTemplatesSo.total, page: options.page, perPage: options.perPage, diff --git a/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_templates.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_templates.test.ts new file mode 100644 index 00000000000000..f894d031c4bc01 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/csp_rule_template/get_csp_rule_templates.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getSortedCspRulesTemplates } from './get_csp_rule_template'; +import { CspRuleTemplate } from '../../../common/schemas'; + +describe('getSortedCspRulesTemplates', () => { + it('sorts by metadata.benchmark.rule_number, invalid semantic version still should still get sorted and empty values should be sorted last', () => { + const cspRulesTemplates = [ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: '2.0.0' } } }, + { metadata: { benchmark: { rule_number: '1.1.0' } } }, + { metadata: { benchmark: { rule_number: '1.0.1' } } }, + { metadata: { benchmark: { rule_number: 'invalid' } } }, + { metadata: { benchmark: { rule_number: '3.0' } } }, + { metadata: { benchmark: {} } }, + ] as CspRuleTemplate[]; + + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + + expect(sortedCspRulesTemplates).toEqual([ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: '1.0.1' } } }, + { metadata: { benchmark: { rule_number: '1.1.0' } } }, + { metadata: { benchmark: { rule_number: '2.0.0' } } }, + { metadata: { benchmark: { rule_number: '3.0' } } }, + { metadata: { benchmark: { rule_number: 'invalid' } } }, + { metadata: { benchmark: {} } }, + ]); + }); + + it('edge case - returns empty array if input is empty', () => { + const cspRulesTemplates: CspRuleTemplate[] = []; + + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + + expect(sortedCspRulesTemplates).toEqual([]); + }); + + it('edge case - returns sorted array even if input only has one element', () => { + const cspRulesTemplates = [ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + ] as CspRuleTemplate[]; + + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + + expect(sortedCspRulesTemplates).toEqual([ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + ]); + }); + + it('returns sorted array even with undefined or null properties', () => { + const cspRulesTemplates = [ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: undefined } } }, + { metadata: { benchmark: { rule_number: '2.0.0' } } }, + { metadata: { benchmark: { rule_number: null } } }, + ] as CspRuleTemplate[]; + + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + + expect(sortedCspRulesTemplates).toEqual([ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: '2.0.0' } } }, + { metadata: { benchmark: { rule_number: null } } }, + { metadata: { benchmark: { rule_number: undefined } } }, + ]); + }); + + it('returns sorted array with invalid semantic versions', () => { + const cspRulesTemplates = [ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: '2.0' } } }, + { metadata: { benchmark: { rule_number: '3.0.0' } } }, + ] as CspRuleTemplate[]; + + const sortedCspRulesTemplates = getSortedCspRulesTemplates(cspRulesTemplates); + + expect(sortedCspRulesTemplates).toEqual([ + { metadata: { benchmark: { rule_number: '1.0.0' } } }, + { metadata: { benchmark: { rule_number: '2.0' } } }, + { metadata: { benchmark: { rule_number: '3.0.0' } } }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts index 6645796b5dc6c6..95c6672df69280 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts @@ -28,7 +28,6 @@ import { export const TEXT_EXPANSION_TYPE = SUPPORTED_PYTORCH_TASKS.TEXT_EXPANSION; export const TEXT_EXPANSION_FRIENDLY_TYPE = 'ELSER'; export const ML_INFERENCE_PREFIX = 'ml.inference.'; -export const ELSER_MODEL_ID = '.elser_model_1'; export interface MlInferencePipelineParams { description?: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts index f374fe8b606668..65c4a16b084d62 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts @@ -13,6 +13,7 @@ import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { LensPublicStart } from '@kbn/lens-plugin/public'; +import { mlPluginMock } from '@kbn/ml-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; @@ -60,6 +61,7 @@ export const mockKibanaValues = { setChromeIsVisible: jest.fn(), setDocTitle: jest.fn(), share: sharePluginMock.createStartContract(), + ml: mlPluginMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/create_text_expansion_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/create_text_expansion_model_api_logic.ts index 643352e0a833e2..b780e2f6364aef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/create_text_expansion_model_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/create_text_expansion_model_api_logic.ts @@ -4,22 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ELSER_MODEL_ID } from '../../../../../../common/ml_inference_pipeline'; import { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../../shared/http'; -export type CreateTextExpansionModelArgs = undefined; +export interface CreateTextExpansionModelArgs { + modelId: string; +} export interface CreateTextExpansionModelResponse { deploymentState: string; modelId: string; } -export const createTextExpansionModel = async (): Promise => { - const route = `/internal/enterprise_search/ml/models/${ELSER_MODEL_ID}`; - return await HttpLogic.values.http.post(route, { - body: undefined, - }); +export const createTextExpansionModel = async ({ + modelId, +}: CreateTextExpansionModelArgs): Promise => { + const route = `/internal/enterprise_search/ml/models/${modelId}`; + return await HttpLogic.values.http.post(route); }; export const CreateTextExpansionModelApiLogic = createApiLogic( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/fetch_text_expansion_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/fetch_text_expansion_model_api_logic.ts index c53b9a7d5ca5c9..d622101f6caa82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/fetch_text_expansion_model_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/fetch_text_expansion_model_api_logic.ts @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ELSER_MODEL_ID } from '../../../../../../common/ml_inference_pipeline'; import { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../../shared/http'; -export type FetchTextExpansionModelArgs = undefined; +export interface FetchTextExpansionModelArgs { + modelId: string; +} export interface FetchTextExpansionModelResponse { deploymentState: string; @@ -18,9 +19,9 @@ export interface FetchTextExpansionModelResponse { threadsPerAllocation: number; } -export const fetchTextExpansionModelStatus = async () => { +export const fetchTextExpansionModelStatus = async ({ modelId }: FetchTextExpansionModelArgs) => { return await HttpLogic.values.http.get( - `/internal/enterprise_search/ml/models/${ELSER_MODEL_ID}` + `/internal/enterprise_search/ml/models/${modelId}` ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/start_text_expansion_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/start_text_expansion_model_api_logic.ts index d8e1116154b647..f9f57800763bec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/start_text_expansion_model_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/text_expansion/start_text_expansion_model_api_logic.ts @@ -4,22 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ELSER_MODEL_ID } from '../../../../../../common/ml_inference_pipeline'; import { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../../shared/http'; -export type StartTextExpansionModelArgs = undefined; +export interface StartTextExpansionModelArgs { + modelId: string; +} export interface StartTextExpansionModelResponse { deploymentState: string; modelId: string; } -export const startTextExpansionModel = async (): Promise => { - const route = `/internal/enterprise_search/ml/models/${ELSER_MODEL_ID}/deploy`; - return await HttpLogic.values.http.post(route, { - body: undefined, - }); +export const startTextExpansionModel = async ({ + modelId, +}: StartTextExpansionModelArgs): Promise => { + const route = `/internal/enterprise_search/ml/models/${modelId}/deploy`; + return await HttpLogic.values.http.post(route); }; export const StartTextExpansionModelApiLogic = createApiLogic( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/documents.tsx index 694639b1d19c64..b2932c9547e27a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/documents.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useState, ChangeEvent } from 'react'; import { useActions, useValues } from 'kea'; import { + EuiCallOut, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, @@ -74,7 +75,7 @@ export const SearchIndexDocuments: React.FC = () => { const { makeRequest: getDocuments } = useActions(documentLogic); const { makeRequest: getMappings } = useActions(mappingLogic); - const { data, status } = useValues(documentLogic); + const { data, status, error } = useValues(documentLogic); const { data: mappingData, status: mappingStatus } = useValues(mappingLogic); const docs = data?.results?.hits.hits ?? []; @@ -84,6 +85,8 @@ export const SearchIndexDocuments: React.FC = () => { const shouldShowAccessControlSwitcher = hasDocumentLevelSecurityFeature && productFeatures.hasDocumentLevelSecurityEnabled; + const isAccessControlIndexNotFound = + shouldShowAccessControlSwitcher && error?.body?.statusCode === 404; useEffect(() => { getDocuments({ @@ -141,11 +144,29 @@ export const SearchIndexDocuments: React.FC = () => { - {docs.length === 0 && + {isAccessControlIndexNotFound && ( + +

+ {i18n.translate('xpack.enterpriseSearch.content.searchIndex.documents.noIndex', { + defaultMessage: + "An Access Control Index won't be created until you enable document-level security and run your first access control sync.", + })} +

+
+ )} + {!isAccessControlIndexNotFound && + docs.length === 0 && i18n.translate('xpack.enterpriseSearch.content.searchIndex.documents.noMappings', { defaultMessage: 'No documents found for index', })} - {docs.length > 0 && ( + {!isAccessControlIndexNotFound && docs.length > 0 && ( { ? indexName : stripSearchPrefix(indexName, CONNECTORS_ACCESS_CONTROL_INDEX_PREFIX); const { makeRequest: makeMappingRequest } = useActions(mappingsWithPropsApiLogic(indexToShow)); - const { data: mappingData } = useValues(mappingsWithPropsApiLogic(indexToShow)); + const { data: mappingData, error } = useValues(mappingsWithPropsApiLogic(indexToShow)); const shouldShowAccessControlSwitch = hasDocumentLevelSecurityFeature && productFeatures.hasDocumentLevelSecurityEnabled; + const isAccessControlIndexNotFound = + shouldShowAccessControlSwitch && error?.body?.statusCode === 404; + useEffect(() => { makeMappingRequest({ indexName: indexToShow }); }, [indexToShow, indexName]); @@ -77,9 +81,27 @@ export const SearchIndexIndexMappings: React.FC = () => {
)} - - {JSON.stringify(mappingData, null, 2)} - + {isAccessControlIndexNotFound ? ( + +

+ {i18n.translate('xpack.enterpriseSearch.content.searchIndex.mappings.noIndex', { + defaultMessage: + "An Access Control Index won't be created until you enable document-level security and run your first access control sync.", + })} +

+
+ ) : ( + + {JSON.stringify(mappingData, null, 2)} + + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/deploy_model.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/deploy_model.tsx index 40a0059b55eaeb..5431c7c41454cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/deploy_model.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/deploy_model.tsx @@ -91,7 +91,7 @@ export const DeployModel = ({ data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-textExpansionCallOut-deployModel`} disabled={isCreateButtonDisabled} iconType="launch" - onClick={() => createTextExpansionModel(undefined)} + onClick={() => createTextExpansionModel()} > {i18n.translate( 'xpack.enterpriseSearch.content.indices.pipelines.textExpansionCallOut.deployButton.label', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_deployed.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_deployed.tsx index 8c0694cbb8405f..fe8f0b7953c7d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_deployed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_deployed.tsx @@ -92,7 +92,7 @@ export const ModelDeployed = ({ data-telemetry-id={`entSearchContent-${ingestionMethod}-pipelines-textExpansionCallOut-startModel`} disabled={isStartButtonDisabled} iconType="playFilled" - onClick={() => startTextExpansionModel(undefined)} + onClick={() => startTextExpansionModel()} > {i18n.translate( 'xpack.enterpriseSearch.content.indices.pipelines.textExpansionCallOut.startModelButton.label', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts index 0227f52b0c0410..e36b57d6ee1106 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.test.ts @@ -38,6 +38,7 @@ const DEFAULT_VALUES: TextExpansionCalloutValues = { textExpansionModel: undefined, textExpansionModelPollTimeoutId: null, textExpansionError: null, + elserModelId: '.elser_model_2', }; jest.useFakeTimers(); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts index 86721c7808f14b..e808e8cbf74403 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout_logic.ts @@ -12,6 +12,9 @@ import { i18n } from '@kbn/i18n'; import { HttpError, Status } from '../../../../../../../common/types/api'; import { MlModelDeploymentState } from '../../../../../../../common/types/ml'; import { getErrorsFromHttpResponse } from '../../../../../shared/flash_messages/handle_api_errors'; + +import { KibanaLogic } from '../../../../../shared/kibana'; + import { CreateTextExpansionModelApiLogic, CreateTextExpansionModelApiLogicActions, @@ -32,20 +35,24 @@ const FETCH_TEXT_EXPANSION_MODEL_POLLING_DURATION_ON_FAILURE = 30000; // 30 seco interface TextExpansionCalloutActions { clearTextExpansionModelPollingId: () => void; - createTextExpansionModel: CreateTextExpansionModelApiLogicActions['makeRequest']; + createTextExpansionModel: () => void; + createTextExpansionModelMakeRequest: CreateTextExpansionModelApiLogicActions['makeRequest']; createTextExpansionModelPollingTimeout: (duration: number) => { duration: number }; createTextExpansionModelSuccess: CreateTextExpansionModelApiLogicActions['apiSuccess']; - fetchTextExpansionModel: FetchTextExpansionModelApiLogicActions['makeRequest']; + fetchTextExpansionModel: () => void; + fetchTextExpansionModelMakeRequest: FetchTextExpansionModelApiLogicActions['makeRequest']; fetchTextExpansionModelError: FetchTextExpansionModelApiLogicActions['apiError']; fetchTextExpansionModelSuccess: FetchTextExpansionModelApiLogicActions['apiSuccess']; setTextExpansionModelPollingId: (pollTimeoutId: ReturnType) => { pollTimeoutId: ReturnType; }; startPollingTextExpansionModel: () => void; - startTextExpansionModel: StartTextExpansionModelApiLogicActions['makeRequest']; + startTextExpansionModel: () => void; + startTextExpansionModelMakeRequest: StartTextExpansionModelApiLogicActions['makeRequest']; startTextExpansionModelSuccess: StartTextExpansionModelApiLogicActions['apiSuccess']; stopPollingTextExpansionModel: () => void; textExpansionModel: FetchTextExpansionModelApiLogicActions['apiSuccess']; + setElserModelId: (elserModelId: string) => { elserModelId: string }; } export interface TextExpansionCalloutError { @@ -54,6 +61,7 @@ export interface TextExpansionCalloutError { } export interface TextExpansionCalloutValues { + elserModelId: string; createTextExpansionModelError: HttpError | undefined; createTextExpansionModelStatus: Status; createdTextExpansionModel: CreateTextExpansionModelResponse | undefined; @@ -128,24 +136,28 @@ export const TextExpansionCalloutLogic = kea< }), startPollingTextExpansionModel: true, stopPollingTextExpansionModel: true, + setElserModelId: (elserModelId) => ({ elserModelId }), + createTextExpansionModel: true, + fetchTextExpansionModel: true, + startTextExpansionModel: true, }, connect: { actions: [ CreateTextExpansionModelApiLogic, [ - 'makeRequest as createTextExpansionModel', + 'makeRequest as createTextExpansionModelMakeRequest', 'apiSuccess as createTextExpansionModelSuccess', 'apiError as createTextExpansionModelError', ], FetchTextExpansionModelApiLogic, [ - 'makeRequest as fetchTextExpansionModel', + 'makeRequest as fetchTextExpansionModelMakeRequest', 'apiSuccess as fetchTextExpansionModelSuccess', 'apiError as fetchTextExpansionModelError', ], StartTextExpansionModelApiLogic, [ - 'makeRequest as startTextExpansionModel', + 'makeRequest as startTextExpansionModelMakeRequest', 'apiSuccess as startTextExpansionModelSuccess', 'apiError as startTextExpansionModelError', ], @@ -164,8 +176,12 @@ export const TextExpansionCalloutLogic = kea< ], }, events: ({ actions, values }) => ({ - afterMount: () => { - actions.fetchTextExpansionModel(undefined); + afterMount: async () => { + const elserModel = await KibanaLogic.values.ml.elasticModels?.getELSER({ version: 2 }); + if (elserModel != null) { + actions.setElserModelId(elserModel.name); + actions.fetchTextExpansionModel(); + } }, beforeUnmount: () => { if (values.textExpansionModelPollTimeoutId !== null) { @@ -174,17 +190,23 @@ export const TextExpansionCalloutLogic = kea< }, }), listeners: ({ actions, values }) => ({ + createTextExpansionModel: () => + actions.createTextExpansionModelMakeRequest({ modelId: values.elserModelId }), + fetchTextExpansionModel: () => + actions.fetchTextExpansionModelMakeRequest({ modelId: values.elserModelId }), + startTextExpansionModel: () => + actions.startTextExpansionModelMakeRequest({ modelId: values.elserModelId }), createTextExpansionModelPollingTimeout: ({ duration }) => { if (values.textExpansionModelPollTimeoutId !== null) { clearTimeout(values.textExpansionModelPollTimeoutId); } const timeoutId = setTimeout(() => { - actions.fetchTextExpansionModel(undefined); + actions.fetchTextExpansionModel(); }, duration); actions.setTextExpansionModelPollingId(timeoutId); }, createTextExpansionModelSuccess: () => { - actions.fetchTextExpansionModel(undefined); + actions.fetchTextExpansionModel(); actions.startPollingTextExpansionModel(); }, fetchTextExpansionModelError: () => { @@ -217,7 +239,7 @@ export const TextExpansionCalloutLogic = kea< actions.createTextExpansionModelPollingTimeout(FETCH_TEXT_EXPANSION_MODEL_POLLING_DURATION); }, startTextExpansionModelSuccess: () => { - actions.fetchTextExpansionModel(undefined); + actions.fetchTextExpansionModel(); }, stopPollingTextExpansionModel: () => { if (values.textExpansionModelPollTimeoutId !== null) { @@ -235,6 +257,12 @@ export const TextExpansionCalloutLogic = kea< setTextExpansionModelPollingId: (_, { pollTimeoutId }) => pollTimeoutId, }, ], + elserModelId: [ + '', + { + setElserModelId: (_, { elserModelId }) => elserModelId, + }, + ], }, selectors: ({ selectors }) => ({ isCreateButtonDisabled: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 84ce6b866d5c6b..bb5e1a35f18a04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -16,6 +16,7 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { mlPluginMock } from '@kbn/ml-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; @@ -38,6 +39,7 @@ describe('renderApp', () => { licensing: licensingMock.createStart(), security: securityMock.createStart(), share: sharePluginMock.createStartContract(), + ml: mlPluginMock.createStartContract(), user: {}, }, } as any; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index def858dfb6040d..a9c55c372e3ded 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -66,7 +66,7 @@ export const renderApp = ( const { history } = params; const { application, chrome, http, uiSettings } = core; const { capabilities, navigateToUrl } = application; - const { charts, cloud, guidedOnboarding, lens, security, share } = plugins; + const { charts, cloud, guidedOnboarding, lens, security, share, ml } = plugins; const entCloudHost = getCloudEnterpriseSearchHost(plugins.cloud); externalUrl.enterpriseSearchUrl = publicUrl || entCloudHost || config.host || ''; @@ -107,6 +107,7 @@ export const renderApp = ( setChromeIsVisible: chrome.setIsVisible, setDocTitle: chrome.docTitle.change, share, + ml, uiSettings, }); const unmountLicensingLogic = mountLicensingLogic({ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index d816c747e50272..67f605ea350f42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -21,6 +21,7 @@ import { import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; +import { MlPluginStart } from '@kbn/ml-plugin/public'; import { SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; @@ -52,6 +53,7 @@ interface KibanaLogicProps { setChromeIsVisible(isVisible: boolean): void; setDocTitle(title: string): void; share: SharePluginStart; + ml: MlPluginStart; uiSettings: IUiSettingsClient; } @@ -92,6 +94,7 @@ export const KibanaLogic = kea>({ setChromeIsVisible: [props.setChromeIsVisible, {}], setDocTitle: [props.setDocTitle, {}], share: [props.share, {}], + ml: [props.ml, {}], uiSettings: [props.uiSettings, {}], }), selectors: ({ selectors }) => ({ diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 5fde4b3c41d850..ce116ee52c2a62 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -22,6 +22,7 @@ import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/publi import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { MlPluginStart } from '@kbn/ml-plugin/public'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; @@ -65,6 +66,7 @@ export interface PluginsStart { licensing: LicensingPluginStart; security: SecurityPluginStart; share: SharePluginStart; + ml: MlPluginStart; } export class EnterpriseSearchPlugin implements Plugin { @@ -415,7 +417,7 @@ export class EnterpriseSearchPlugin implements Plugin { } } - public start(core: CoreStart) { + public async start(core: CoreStart) { if (!this.config.ui?.enabled) { return; } diff --git a/x-pack/plugins/enterprise_search/server/lib/ml/ml_model_deployment_common.ts b/x-pack/plugins/enterprise_search/server/lib/ml/ml_model_deployment_common.ts index 9465a943014431..59498b4d8587aa 100644 --- a/x-pack/plugins/enterprise_search/server/lib/ml/ml_model_deployment_common.ts +++ b/x-pack/plugins/enterprise_search/server/lib/ml/ml_model_deployment_common.ts @@ -5,13 +5,15 @@ * 2.0. */ +import { ELASTIC_MODEL_DEFINITIONS } from '@kbn/ml-trained-models-utils'; + import { ElasticsearchResponseError, isNotFoundException, isResourceNotFoundException, } from '../../utils/identify_exceptions'; -export const acceptableModelNames = ['.elser_model_1', '.elser_model_1_SNAPSHOT']; +export const acceptableModelNames = Object.keys(ELASTIC_MODEL_DEFINITIONS); export function isNotFoundExceptionError(error: unknown): boolean { return ( diff --git a/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_deployment.test.ts b/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_deployment.test.ts index 619debc514f2ad..ad516bad20dc0f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_deployment.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_deployment.test.ts @@ -15,8 +15,7 @@ import * as mockGetStatus from './get_ml_model_deployment_status'; import { startMlModelDeployment } from './start_ml_model_deployment'; describe('startMlModelDeployment', () => { - const productionModelName = '.elser_model_1'; - const snapshotModelName = '.elser_model_1_SNAPSHOT'; + const modelName = '.elser_model_2_SNAPSHOT'; const mockTrainedModelsProvider = { getTrainedModels: jest.fn(), getTrainedModelsStats: jest.fn(), @@ -28,7 +27,7 @@ describe('startMlModelDeployment', () => { }); it('should error when there is no trained model provider', () => { - expect(() => startMlModelDeployment(productionModelName, undefined)).rejects.toThrowError( + expect(() => startMlModelDeployment(modelName, undefined)).rejects.toThrowError( 'Machine Learning is not enabled' ); }); @@ -50,7 +49,7 @@ describe('startMlModelDeployment', () => { jest.spyOn(mockGetStatus, 'getMlModelDeploymentStatus').mockReturnValueOnce( Promise.resolve({ deploymentState: MlModelDeploymentState.Starting, - modelId: productionModelName, + modelId: modelName, nodeAllocationCount: 0, startTime: 123456, targetAllocationCount: 3, @@ -59,7 +58,7 @@ describe('startMlModelDeployment', () => { ); const response = await startMlModelDeployment( - productionModelName, + modelName, mockTrainedModelsProvider as unknown as MlTrainedModels ); @@ -70,7 +69,7 @@ describe('startMlModelDeployment', () => { jest.spyOn(mockGetStatus, 'getMlModelDeploymentStatus').mockReturnValueOnce( Promise.resolve({ deploymentState: MlModelDeploymentState.Starting, - modelId: snapshotModelName, + modelId: modelName, nodeAllocationCount: 0, startTime: 123456, targetAllocationCount: 3, @@ -79,7 +78,7 @@ describe('startMlModelDeployment', () => { ); const response = await startMlModelDeployment( - snapshotModelName, + modelName, mockTrainedModelsProvider as unknown as MlTrainedModels ); @@ -92,7 +91,7 @@ describe('startMlModelDeployment', () => { .mockReturnValueOnce( Promise.resolve({ deploymentState: MlModelDeploymentState.Downloaded, - modelId: productionModelName, + modelId: modelName, nodeAllocationCount: 0, startTime: 123456, targetAllocationCount: 3, @@ -102,7 +101,7 @@ describe('startMlModelDeployment', () => { .mockReturnValueOnce( Promise.resolve({ deploymentState: MlModelDeploymentState.Starting, - modelId: productionModelName, + modelId: modelName, nodeAllocationCount: 0, startTime: 123456, targetAllocationCount: 3, @@ -112,7 +111,7 @@ describe('startMlModelDeployment', () => { mockTrainedModelsProvider.startTrainedModelDeployment.mockImplementation(async () => {}); const response = await startMlModelDeployment( - productionModelName, + modelName, mockTrainedModelsProvider as unknown as MlTrainedModels ); expect(response.deploymentState).toEqual(MlModelDeploymentState.Starting); diff --git a/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_download.test.ts b/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_download.test.ts index 084a6382a6c36a..0d3f3add793b0f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_download.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/ml/start_ml_model_download.test.ts @@ -15,7 +15,7 @@ import * as mockGetStatus from './get_ml_model_deployment_status'; import { startMlModelDownload } from './start_ml_model_download'; describe('startMlModelDownload', () => { - const knownModelName = '.elser_model_1_SNAPSHOT'; + const knownModelName = '.elser_model_2_SNAPSHOT'; const mockTrainedModelsProvider = { getTrainedModels: jest.fn(), getTrainedModelsStats: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts index 1a8412ba8703c0..f285a309fb53eb 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts @@ -1089,7 +1089,7 @@ describe('Enterprise Search Managed Indices', () => { router: mockRouter.router, }); }); - const modelName = '.elser_model_1_SNAPSHOT'; + const modelName = '.elser_model_2_SNAPSHOT'; it('fails validation without modelName', () => { const request = { @@ -1153,7 +1153,7 @@ describe('Enterprise Search Managed Indices', () => { router: mockRouter.router, }); }); - const modelName = '.elser_model_1_SNAPSHOT'; + const modelName = '.elser_model_2_SNAPSHOT'; it('fails validation without modelName', () => { const request = { @@ -1216,7 +1216,7 @@ describe('Enterprise Search Managed Indices', () => { router: mockRouter.router, }); }); - const modelName = '.elser_model_1_SNAPSHOT'; + const modelName = '.elser_model_2_SNAPSHOT'; it('fails validation without modelName', () => { const request = { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 334ef577c5fdfa..dfa970fb4b43c5 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -572,6 +572,24 @@ "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "in": "query", + "name": "ignoreMappingUpdateErrors", + "schema": { + "type": "boolean", + "default": false + }, + "description": "avoid erroring out on unexpected mapping update errors" + }, + { + "in": "query", + "name": "skipDataStreamRollover", + "schema": { + "type": "boolean", + "default": false + }, + "description": "skip data stream rollover during index template mapping or settings update" } ], "requestBody": { @@ -810,6 +828,24 @@ "name": "pkgkey", "in": "path", "required": true + }, + { + "in": "query", + "name": "ignoreMappingUpdateErrors", + "schema": { + "type": "boolean", + "default": false + }, + "description": "avoid erroring out on unexpected mapping update errors" + }, + { + "in": "query", + "name": "skipDataStreamRollover", + "schema": { + "type": "boolean", + "default": false + }, + "description": "skip data stream rollover during index template mapping or settings update" } ], "requestBody": { @@ -1090,6 +1126,24 @@ "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "in": "query", + "name": "ignoreMappingUpdateErrors", + "schema": { + "type": "boolean", + "default": false + }, + "description": "avoid erroring out on unexpected mapping update errors" + }, + { + "in": "query", + "name": "skipDataStreamRollover", + "schema": { + "type": "boolean", + "default": false + }, + "description": "skip data stream rollover during index template mapping or settings update" } ], "requestBody": { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 6ba43b73ba6436..a996c3403810db 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -368,6 +368,20 @@ paths: description: '' parameters: - $ref: '#/components/parameters/kbn_xsrf' + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: >- + skip data stream rollover during index template mapping or settings + update requestBody: content: application/zip: @@ -516,6 +530,20 @@ paths: name: pkgkey in: path required: true + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: >- + skip data stream rollover during index template mapping or settings + update requestBody: content: application/json: @@ -689,6 +717,20 @@ paths: description: '' parameters: - $ref: '#/components/parameters/kbn_xsrf' + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: >- + skip data stream rollover during index template mapping or settings + update requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml index 7434fd2d324a5f..f0dbc48d7dc503 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml @@ -83,6 +83,18 @@ post: description: '' parameters: - $ref: ../components/headers/kbn_xsrf.yaml + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: skip data stream rollover during index template mapping or settings update requestBody: content: application/zip: diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml index bc97c46bd4cd28..3d788b9f24e229 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -111,6 +111,18 @@ post: description: '' parameters: - $ref: ../components/headers/kbn_xsrf.yaml + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: skip data stream rollover during index template mapping or settings update requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}_deprecated.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}_deprecated.yaml index 035809782bc071..7ea8b18d413dc5 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}_deprecated.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}_deprecated.yaml @@ -84,6 +84,18 @@ post: name: pkgkey in: path required: true + - in: query + name: ignoreMappingUpdateErrors + schema: + type: boolean + default: false + description: avoid erroring out on unexpected mapping update errors + - in: query + name: skipDataStreamRollover + schema: + type: boolean + default: false + description: skip data stream rollover during index template mapping or settings update requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.test.tsx new file mode 100644 index 00000000000000..07f42b65fdb0cd --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 { createFleetTestRendererMock } from '../../../../../../mock'; + +import { AgentsSelectionStatus } from './agents_selection_status'; + +function render(props: any) { + const renderer = createFleetTestRendererMock(); + + return renderer.render(); +} + +const defaultProps = { + totalAgents: 30, + selectableAgents: 20, + managedAgentsOnCurrentPage: 0, + selectionMode: 'manual', + setSelectionMode: jest.fn(), + selectedAgents: [], + setSelectedAgents: jest.fn(), +}; + +function generateAgents(n: number) { + return [...Array(n).keys()].map((i) => ({ + id: `agent${i}`, + active: true, + })); +} + +describe('AgentsSelectionStatus', () => { + describe('when selection mode is manual', () => { + describe('when there are no selected agents', () => { + it('should not show any selection options', () => { + const res = render(defaultProps); + expect(res.queryByTestId('selectedAgentCountLabel')).toBeNull(); + expect(res.queryByTestId('clearAgentSelectionButton')).toBeNull(); + expect(res.queryByTestId('selectedEverythingOnAllPagesButton')).toBeNull(); + }); + }); + + describe('when there are selected agents', () => { + it('should show the number of selected agents and the Clear selection button', () => { + const res = render({ ...defaultProps, selectedAgents: generateAgents(2) }); + expect(res.queryByTestId('selectedAgentCountLabel')).not.toBeNull(); + expect(res.queryByTestId('clearAgentSelectionButton')).not.toBeNull(); + }); + + it('should not show the Select everything on all pages button if not all agents are selected', () => { + const res = render({ ...defaultProps, selectedAgents: generateAgents(2) }); + expect(res.queryByTestId('selectedEverythingOnAllPagesButton')).toBeNull(); + }); + + it('should not show the Select everything on all pages button if all agents are selected but there are no more selectable agents', () => { + const res = render({ + ...defaultProps, + totalAgents: 20, + selectableAgents: 19, + managedAgentsOnCurrentPage: 1, + selectedAgents: generateAgents(19), + }); + expect(res.queryByTestId('selectedEverythingOnAllPagesButton')).toBeNull(); + }); + + it('should show the Select everything on all pages button if all agents are selected and there are more selectable agents', () => { + const res = render({ ...defaultProps, selectedAgents: generateAgents(20) }); + expect(res.queryByTestId('selectedEverythingOnAllPagesButton')).not.toBeNull(); + }); + }); + }); + + describe('when selection mode is query', () => { + describe('when there are agents', () => { + it('should show the number of selected agents and the Clear selection button', () => { + const res = render({ + ...defaultProps, + selectionMode: 'query', + selectedAgents: generateAgents(2), + }); + expect(res.queryByTestId('selectedAgentCountLabel')).not.toBeNull(); + expect(res.queryByTestId('clearAgentSelectionButton')).not.toBeNull(); + }); + + it('should not show the Select everything on all pages button', () => { + const res = render({ + ...defaultProps, + selectionMode: 'query', + selectedAgents: generateAgents(20), + }); + expect(res.queryByTestId('selectedEverythingOnAllPagesButton')).toBeNull(); + }); + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.tsx index 57728f275ccb56..0e1b6e43db46ed 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agents_selection_status.tsx @@ -34,6 +34,7 @@ const Button = styled(EuiButtonEmpty)` export const AgentsSelectionStatus: React.FunctionComponent<{ totalAgents: number; selectableAgents: number; + managedAgentsOnCurrentPage: number; selectionMode: SelectionMode; setSelectionMode: (mode: SelectionMode) => void; selectedAgents: Agent[]; @@ -41,15 +42,19 @@ export const AgentsSelectionStatus: React.FunctionComponent<{ }> = ({ totalAgents, selectableAgents, + managedAgentsOnCurrentPage, selectionMode, setSelectionMode, selectedAgents, setSelectedAgents, }) => { + const showSelectionInfoAndOptions = + (selectionMode === 'manual' && selectedAgents.length > 0) || + (selectionMode === 'query' && totalAgents > 0); const showSelectEverything = selectionMode === 'manual' && selectedAgents.length === selectableAgents && - selectableAgents < totalAgents; + selectableAgents < totalAgents - managedAgentsOnCurrentPage; return ( <> @@ -74,14 +79,13 @@ export const AgentsSelectionStatus: React.FunctionComponent<{ )} - {(selectionMode === 'manual' && selectedAgents.length) || - (selectionMode === 'query' && totalAgents > 0) ? ( + {showSelectionInfoAndOptions ? ( <> - + -