From facdd55b1d963abe795c860b86098c7e5a6e0905 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Thu, 11 Feb 2021 07:47:44 -0800 Subject: [PATCH 01/26] Fix datagrid issue in Discover for Firefox (#90906) * Fix datagrid issue in Discover for Firefox * small visual cleanup while im in here --- .../components/discover_grid/discover_grid.scss | 12 ++++++++++++ .../discover_grid/get_render_cell_value.test.tsx | 2 +- .../discover_grid/get_render_cell_value.tsx | 7 +++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss index 64a7eda963349b..4754c1700f28de 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.scss @@ -9,6 +9,14 @@ border-bottom: $euiBorderThin; } + .euiDataGridRowCell.euiDataGridRowCell--firstColumn { + border-left: none; + } + + .euiDataGridRowCell.euiDataGridRowCell--lastColumn { + border-right: none; + } + .euiDataGridRowCell:first-of-type, .euiDataGrid--headerShade.euiDataGrid--bordersAll .euiDataGridHeaderCell:first-of-type { border-left: none; @@ -66,3 +74,7 @@ .dscFormatSource { @include euiTextTruncate; } + +.dscDiscoverGrid__descriptionListDescription { + word-break: normal !important; +} diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 49dc43d88fa108..594aaac2168d4a 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -56,7 +56,7 @@ describe('Discover grid cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
bytes
100
"` + `"
bytes
100
"` ); }); diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index 840b4d398be0e8..6ed19813830c85 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -60,11 +60,14 @@ export const getRenderCellValueFn = ( const formatted = indexPattern.formatHit(row); return ( - + {Object.keys(formatted).map((key) => ( {key} - + ))} From 1fdd6ad63903adfeb692e90335451ffb32bba8fa Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 11 Feb 2021 11:15:11 -0500 Subject: [PATCH 02/26] [Security Solution][Timeline] - Open Host & Network details in side panel (#90064) --- .../common/types/timeline/index.ts | 36 +- .../cases/components/case_view/index.test.tsx | 7 +- .../cases/components/case_view/index.tsx | 12 +- .../events_viewer/event_details_flyout.tsx | 106 -- .../events_viewer/events_viewer.test.tsx | 7 +- .../events_viewer/events_viewer.tsx | 8 +- .../common/components/events_viewer/index.tsx | 14 +- .../public/common/components/links/index.tsx | 31 +- .../overview_description_list/index.tsx | 26 + .../public/common/mock/global_state.ts | 2 +- .../public/common/mock/timeline_results.ts | 4 +- .../components/alerts_table/actions.test.tsx | 2 +- .../public/hosts/pages/details/index.tsx | 1 + .../details/__snapshots__/index.test.tsx.snap | 152 +++ .../network/components/details/index.test.tsx | 15 + .../network/components/details/index.tsx | 47 +- .../network/components/ip/index.test.tsx | 7 +- .../public/network/pages/details/index.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 205 ++++ .../host_overview/endpoint_overview/index.tsx | 139 +-- .../components/host_overview/index.test.tsx | 42 +- .../components/host_overview/index.tsx | 46 +- .../field_renderers/field_renderers.tsx | 34 +- .../components/formatted_ip/index.tsx | 74 +- .../components/open_timeline/helpers.test.ts | 16 +- .../open_timeline/note_previews/index.tsx | 5 +- .../__snapshots__/index.test.tsx.snap | 1029 +++++++++++++++++ .../event_details/expandable_event.tsx} | 4 +- .../side_panel/event_details/index.tsx | 109 ++ .../event_details/translations.ts} | 14 - .../host_details/expandable_host.tsx | 94 ++ .../side_panel/host_details/index.tsx | 116 ++ .../components/side_panel/index.test.tsx | 204 ++++ .../timelines/components/side_panel/index.tsx | 120 ++ .../network_details/expandable_network.tsx | 134 +++ .../side_panel/network_details/index.tsx | 113 ++ .../timeline/body/actions/index.test.tsx | 6 +- .../timeline/body/actions/index.tsx | 9 +- .../body/events/event_column_view.test.tsx | 2 +- .../body/events/event_column_view.tsx | 10 +- .../timeline/body/events/stateful_event.tsx | 174 +-- .../body/events/stateful_event_context.tsx | 17 + .../components/timeline/body/index.test.tsx | 15 +- .../components/timeline/body/index.tsx | 4 + .../body/renderers/formatted_field.test.tsx | 8 +- .../timeline/body/renderers/host_name.tsx | 58 +- .../components/timeline/event_details.tsx | 85 -- .../timelines/components/timeline/index.tsx | 6 +- .../timeline/notes_tab_content/index.tsx | 20 +- .../timeline/notes_tab_content/selectors.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../pinned_tab_content/index.test.tsx | 2 +- .../timeline/pinned_tab_content/index.tsx | 25 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../timeline/query_tab_content/index.test.tsx | 4 +- .../timeline/query_tab_content/index.tsx | 42 +- .../containers/active_timeline_context.ts | 44 +- .../public/timelines/containers/index.tsx | 4 +- .../timelines/store/timeline/actions.ts | 14 +- .../timelines/store/timeline/defaults.ts | 2 +- .../timelines/store/timeline/epic.test.ts | 2 +- .../timeline/epic_local_storage.test.tsx | 4 +- .../timelines/store/timeline/helpers.ts | 30 +- .../public/timelines/store/timeline/model.ts | 7 +- .../timelines/store/timeline/reducer.test.ts | 2 +- .../timelines/store/timeline/reducer.ts | 32 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 68 files changed, 3000 insertions(+), 616 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/overview_description_list/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap rename x-pack/plugins/security_solution/public/timelines/components/{timeline/expandable_event/index.tsx => side_panel/event_details/expandable_event.tsx} (96%) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx rename x-pack/plugins/security_solution/public/timelines/components/{timeline/expandable_event/translations.tsx => side_panel/event_details/translations.ts} (77%) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 26a30e7c8f239e..cee8ccdea3e9e1 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -14,6 +14,7 @@ import { success, success_count as successCount, } from '../../detection_engine/schemas/common/schemas'; +import { FlowTarget } from '../../search_strategy/security_solution/network'; import { PositiveInteger } from '../../detection_engine/schemas/types'; import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; @@ -423,11 +424,38 @@ type EmptyObject = Record; export type TimelineExpandedEventType = | { - eventId: string; - indexName: string; + panelView?: 'eventDetail'; + params?: { + eventId: string; + indexName: string; + }; } | EmptyObject; -export type TimelineExpandedEvent = { - [tab in TimelineTabs]?: TimelineExpandedEventType; +export type TimelineExpandedHostType = + | { + panelView?: 'hostDetail'; + params?: { + hostName: string; + }; + } + | EmptyObject; + +export type TimelineExpandedNetworkType = + | { + panelView?: 'networkDetail'; + params?: { + ip: string; + flowTarget: FlowTarget; + }; + } + | EmptyObject; + +export type TimelineExpandedDetailType = + | TimelineExpandedEventType + | TimelineExpandedHostType + | TimelineExpandedNetworkType; + +export type TimelineExpandedDetail = { + [tab in TimelineTabs]?: TimelineExpandedDetailType; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e74b66eeeb9f07..dc0ef9ad026a41 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -615,7 +615,7 @@ describe('CaseView ', () => { type: 'x-pack/security_solution/local/timeline/CREATE_TIMELINE', payload: { columns: [], - expandedEvent: {}, + expandedDetail: {}, id: 'timeline-case', indexNames: [], show: false, @@ -661,9 +661,10 @@ describe('CaseView ', () => { .first() .simulate('click'); expect(mockDispatch).toHaveBeenCalledWith({ - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', payload: { - event: { eventId: 'alert-id-1', indexName: 'alert-index-1' }, + panelView: 'eventDetail', + params: { eventId: 'alert-id-1', indexName: 'alert-index-1' }, timelineId: 'timeline-case', }, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e690a01dca54b7..0eaa867077a4a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -44,7 +44,7 @@ import { } from '../configure_cases/utils'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; import { buildAlertsQuery, getRuleIdsFromComments } from './helpers'; -import { EventDetailsFlyout } from '../../../common/components/events_viewer/event_details_flyout'; +import { DetailsPanel } from '../../../timelines/components/side_panel'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { TimelineId } from '../../../../common/types/timeline'; @@ -368,9 +368,10 @@ export const CaseComponent = React.memo( const showAlert = useCallback( (alertId: string, index: string) => { dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', timelineId: TimelineId.casePage, - event: { + params: { eventId: alertId, indexName: index, }, @@ -390,7 +391,7 @@ export const CaseComponent = React.memo( id: TimelineId.casePage, columns: [], indexNames: [], - expandedEvent: {}, + expandedDetail: {}, show: false, }) ); @@ -500,9 +501,10 @@ export const CaseComponent = React.memo( - diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx deleted file mode 100644 index 60418f3a2a080b..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ /dev/null @@ -1,106 +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 { some } from 'lodash/fp'; -import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; -import { useDispatch } from 'react-redux'; - -import { BrowserFields, DocValueFields } from '../../containers/source'; -import { - ExpandableEvent, - ExpandableEventTitle, -} from '../../../timelines/components/timeline/expandable_event'; -import { useDeepEqualSelector } from '../../hooks/use_selector'; -import { useTimelineEventsDetails } from '../../../timelines/containers/details'; -import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; -import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - -const StyledEuiFlyout = styled(EuiFlyout)` - z-index: ${({ theme }) => theme.eui.euiZLevel7}; -`; - -const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` - .euiFlyoutBody__overflow { - display: flex; - flex: 1; - overflow: hidden; - - .euiFlyoutBody__overflowContent { - flex: 1; - overflow: hidden; - padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; - } - } -`; - -interface EventDetailsFlyoutProps { - browserFields: BrowserFields; - docValueFields: DocValueFields[]; - timelineId: string; -} - -const EventDetailsFlyoutComponent: React.FC = ({ - browserFields, - docValueFields, - timelineId, -}) => { - const dispatch = useDispatch(); - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent?.query ?? {} - ); - - const handleClearSelection = useCallback(() => { - dispatch(timelineActions.toggleExpandedEvent({ timelineId })); - }, [dispatch, timelineId]); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: expandedEvent?.indexName ?? '', - eventId: expandedEvent?.eventId ?? '', - skip: !expandedEvent.eventId, - }); - - const isAlert = useMemo( - () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), - [detailsData] - ); - - if (!expandedEvent.eventId) { - return null; - } - - return ( - - - - - - - - - ); -}; - -export const EventDetailsFlyout = React.memo( - EventDetailsFlyoutComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId -); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 6dad6c439ce46c..a37528fcb24d7c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -86,7 +86,6 @@ const eventsViewerDefaultProps = { deletedEventIds: [], docValueFields: [], end: to, - expandedEvent: {}, filters: [], id: TimelineId.detectionsPage, indexNames: mockIndexNames, @@ -100,7 +99,6 @@ const eventsViewerDefaultProps = { query: '', language: 'kql', }, - handleCloseExpandedEvent: jest.fn(), start: from, sort: [ { @@ -150,14 +148,15 @@ describe('EventsViewer', () => { expect(mockDispatch).toBeCalledTimes(2); expect(mockDispatch.mock.calls[1][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: 'yb8TkHYBRgU82_bJu_rY', indexName: 'auditbeat-7.10.1-2020.12.18-000001', }, tabType: 'query', timelineId: TimelineId.test, }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 254309aee906bc..012c9a3a450c06 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -40,11 +40,7 @@ import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; -import { - TimelineExpandedEventType, - TimelineId, - TimelineTabs, -} from '../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; @@ -113,7 +109,6 @@ interface Props { deletedEventIds: Readonly; docValueFields: DocValueFields[]; end: string; - expandedEvent: TimelineExpandedEventType; filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; @@ -141,7 +136,6 @@ const EventsViewerComponent: React.FC = ({ deletedEventIds, docValueFields, end, - expandedEvent, filters, headerFilterGroup, id, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 2b5420674b89c3..59dc756bb2b3e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -21,7 +21,7 @@ import { InspectButtonContainer } from '../inspect'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; -import { EventDetailsFlyout } from './event_details_flyout'; +import { DetailsPanel } from '../../../timelines/components/side_panel'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 652; @@ -46,6 +46,11 @@ export interface OwnProps { type Props = OwnProps & PropsFromRedux; +/** + * The stateful events viewer component is the highest level component that is utilized across the security_solution pages layer where + * timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here + * NOTE: As of writting, it is not used in the Case_View component + */ const StatefulEventsViewerComponent: React.FC = ({ createTimeline, columns, @@ -53,7 +58,6 @@ const StatefulEventsViewerComponent: React.FC = ({ deletedEventIds, deleteEventQuery, end, - expandedEvent, excludedRowRendererIds, filters, headerFilterGroup, @@ -114,7 +118,6 @@ const StatefulEventsViewerComponent: React.FC = ({ dataProviders={dataProviders!} deletedEventIds={deletedEventIds} end={end} - expandedEvent={expandedEvent} isLoadingIndexPattern={isLoadingIndexPattern} filters={globalFilters} headerFilterGroup={headerFilterGroup} @@ -133,9 +136,10 @@ const StatefulEventsViewerComponent: React.FC = ({ /> - @@ -155,7 +159,6 @@ const makeMapStateToProps = () => { dataProviders, deletedEventIds, excludedRowRendererIds, - expandedEvent, graphEventId, itemsPerPage, itemsPerPageOptions, @@ -168,7 +171,6 @@ const makeMapStateToProps = () => { columns, dataProviders, deletedEventIds, - expandedEvent: expandedEvent?.query ?? {}, excludedRowRendererIds, filters: getGlobalFiltersQuerySelector(state), id, diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 49d739b3f66798..6b4148db2b1eeb 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -55,10 +55,11 @@ export const LinkAnchor: React.FC = ({ children, ...props }) => ( ); // Internal Links -const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: string }> = ({ - children, - hostName, -}) => { +const HostDetailsLinkComponent: React.FC<{ + children?: React.ReactNode; + hostName: string; + isButton?: boolean; +}> = ({ children, hostName, isButton }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.hosts); const { navigateToApp } = useKibana().services.application; const goToHostDetails = useCallback( @@ -71,7 +72,14 @@ const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: [hostName, navigateToApp, search] ); - return ( + return isButton ? ( + + {children ? children : hostName} + + ) : ( ); }; + export const HostDetailsLink = React.memo(HostDetailsLinkComponent); const allowedUrlSchemes = ['http://', 'https://']; @@ -119,7 +128,8 @@ const NetworkDetailsLinkComponent: React.FC<{ children?: React.ReactNode; ip: string; flowTarget?: FlowTarget | FlowTargetSourceDest; -}> = ({ children, ip, flowTarget = FlowTarget.source }) => { + isButton?: boolean; +}> = ({ children, ip, flowTarget = FlowTarget.source, isButton }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.network); const { navigateToApp } = useKibana().services.application; const goToNetworkDetails = useCallback( @@ -132,7 +142,14 @@ const NetworkDetailsLinkComponent: React.FC<{ [flowTarget, ip, navigateToApp, search] ); - return ( + return isButton ? ( + + {children ? children : ip} + + ) : ( ( + + + +); diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 21e4ef6a46c8c6..bfd25aa469c931 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -214,7 +214,7 @@ export const mockGlobalState: State = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 79486f773b1f21..351caa2df3e317 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2109,7 +2109,7 @@ export const mockTimelineModel: TimelineModel = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { @@ -2232,7 +2232,7 @@ export const defaultTimelineProps: CreateTimelineProps = { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index a2dbeedb3f0166..3c3d79c0c518fa 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -156,7 +156,7 @@ describe('alert actions', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 18ab93dbb340cc..faa240f98e53e9 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -151,6 +151,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta docValueFields={docValueFields} id={id} inspect={inspect} + isInDetailsSidePanel={false} refetch={refetch} setQuery={setQuery} data={hostOverview as HostItem} diff --git a/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap index ca2ce4ee921c70..c22c3bf6807810 100644 --- a/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/details/__snapshots__/index.test.tsx.snap @@ -141,6 +141,158 @@ exports[`IP Overview Component rendering it renders the default IP Overview 1`] flowTarget="source" id="ipOverview" ip="10.10.10.10" + isInDetailsSidePanel={false} + isLoadingAnomaliesData={false} + loading={false} + narrowDateRange={[MockFunction]} + startDate="2019-06-15T06:00:00.000Z" + type="details" + updateFlowTargetAction={[MockFunction]} +/> +`; + +exports[`IP Overview Component rendering it renders the side panel IP overview 1`] = ` + { loading: false, id: 'ipOverview', ip: '10.10.10.10', + isInDetailsSidePanel: false, isLoadingAnomaliesData: false, narrowDateRange: (jest.fn() as unknown) as NarrowDateRange, startDate: '2019-06-15T06:00:00.000Z', @@ -76,5 +77,19 @@ describe('IP Overview Component', () => { expect(wrapper.find('IpOverview')).toMatchSnapshot(); }); + + test('it renders the side panel IP overview', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + const wrapper = shallow( + + + + ); + + expect(wrapper.find('IpOverview')).toMatchSnapshot(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/details/index.tsx b/x-pack/plugins/security_solution/public/network/components/details/index.tsx index 384fffc472e21e..e263d49e22fc01 100644 --- a/x-pack/plugins/security_solution/public/network/components/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/details/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; @@ -27,39 +26,38 @@ import { whoisRenderer, } from '../../../timelines/components/field_renderers/field_renderers'; import * as i18n from './translations'; -import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page'; +import { OverviewWrapper } from '../../../common/components/page'; import { Loader } from '../../../common/components/loader'; import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types'; import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores'; import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; export interface IpOverviewProps { + anomaliesData: Anomalies | null; + contextID?: string; // used to provide unique draggable context when viewing in the side panel data: NetworkDetailsStrategyResponse['networkDetails']; + endDate: string; flowTarget: FlowTarget; id: string; ip: string; - loading: boolean; + isInDetailsSidePanel: boolean; isLoadingAnomaliesData: boolean; - anomaliesData: Anomalies | null; + loading: boolean; + narrowDateRange: NarrowDateRange; startDate: string; - endDate: string; type: networkModel.NetworkType; - narrowDateRange: NarrowDateRange; } -const getDescriptionList = (descriptionList: DescriptionList[], key: number) => ( - - - -); - export const IpOverview = React.memo( ({ + contextID, id, ip, data, + isInDetailsSidePanel = false, // Rather than duplicate the component, alter the structure based on it's location loading, flowTarget, startDate, @@ -77,13 +75,14 @@ export const IpOverview = React.memo( title: i18n.LOCATION, description: locationRenderer( [`${flowTarget}.geo.city_name`, `${flowTarget}.geo.region_name`], - data + data, + contextID ), }, { title: i18n.AUTONOMOUS_SYSTEM, description: typeData - ? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget) + ? autonomousSystemRenderer(typeData.autonomousSystem, flowTarget, contextID) : getEmptyTagValue(), }, ]; @@ -123,12 +122,13 @@ export const IpOverview = React.memo( title: i18n.HOST_ID, description: typeData && data.host - ? hostIdRenderer({ host: data.host, ipFilter: ip }) + ? hostIdRenderer({ host: data.host, ipFilter: ip, contextID }) : getEmptyTagValue(), }, { title: i18n.HOST_NAME, - description: typeData && data.host ? hostNameRenderer(data.host, ip) : getEmptyTagValue(), + description: + typeData && data.host ? hostNameRenderer(data.host, ip, contextID) : getEmptyTagValue(), }, ], [ @@ -139,12 +139,17 @@ export const IpOverview = React.memo( return ( - - - - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) + + {!isInDetailsSidePanel && ( + )} + {descriptionLists.map((descriptionList, index) => ( + + ))} {loading && ( { expect(wrapper.find('[data-test-subj="formatted-ip"]').first().text()).toEqual('10.1.2.3'); }); - test('it hyperlinks to the network/ip page', () => { + test('it dispalys a button which opens the network/ip side panel', () => { const wrapper = mount( @@ -53,8 +53,7 @@ describe('Port', () => { ); expect( - wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().props() - .href - ).toEqual('/ip/10.1.2.3/source'); + wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().text() + ).toEqual('10.1.2.3'); }); }); diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index 124b400d56e92c..896eec39c125cf 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -147,6 +147,7 @@ const NetworkDetailsComponent: React.FC = () => { id={id} inspect={inspect} ip={ip} + isInDetailsSidePanel={false} data={networkDetails} anomaliesData={anomaliesData} loading={loading} diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap index 47d45ab740dcf2..5d7b2d5b85af62 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/__snapshots__/index.test.tsx.snap @@ -196,6 +196,211 @@ exports[`Host Summary Component rendering it renders the default Host Summary 1` endDate="2019-06-18T06:00:00.000Z" id="hostOverview" indexNames={Array []} + isInDetailsSidePanel={false} + isLoadingAnomaliesData={false} + loading={false} + narrowDateRange={[MockFunction]} + startDate="2019-06-15T06:00:00.000Z" +/> +`; + +exports[`Host Summary Component rendering it renders the panel view Host Summary 1`] = ` + ( - - - -); - -export const EndpointOverview = React.memo(({ data }) => { - const getDefaultRenderer = useCallback( - (fieldName: string, fieldData: EndpointFields, attrName: string) => ( - - ), - [] - ); - const descriptionLists: Readonly = useMemo( - () => [ - [ - { - title: i18n.ENDPOINT_POLICY, - description: - data != null && data.endpointPolicy != null ? data.endpointPolicy : getEmptyTagValue(), - }, - ], - [ - { - title: i18n.POLICY_STATUS, - description: - data != null && data.policyStatus != null ? ( - - {data.policyStatus} - - ) : ( - getEmptyTagValue() - ), - }, +export const EndpointOverview = React.memo( + ({ contextID, data, isInDetailsSidePanel = false }) => { + const getDefaultRenderer = useCallback( + (fieldName: string, fieldData: EndpointFields, attrName: string) => ( + + ), + [contextID] + ); + const descriptionLists: Readonly = useMemo( + () => [ + [ + { + title: i18n.ENDPOINT_POLICY, + description: + data != null && data.endpointPolicy != null + ? data.endpointPolicy + : getEmptyTagValue(), + }, + ], + [ + { + title: i18n.POLICY_STATUS, + description: + data != null && data.policyStatus != null ? ( + + {data.policyStatus} + + ) : ( + getEmptyTagValue() + ), + }, + ], + [ + { + title: i18n.SENSORVERSION, + description: + data != null && data.sensorVersion != null + ? getDefaultRenderer('sensorVersion', data, 'agent.version') + : getEmptyTagValue(), + }, + ], + [], // needs 4 columns for design ], - [ - { - title: i18n.SENSORVERSION, - description: - data != null && data.sensorVersion != null - ? getDefaultRenderer('sensorVersion', data, 'agent.version') - : getEmptyTagValue(), - }, - ], - [], // needs 4 columns for design - ], - [data, getDefaultRenderer] - ); + [data, getDefaultRenderer] + ); - return ( - <> - {descriptionLists.map((descriptionList, index) => getDescriptionList(descriptionList, index))} - - ); -}); + return ( + <> + {descriptionLists.map((descriptionList, index) => ( + + ))} + + ); + } +); EndpointOverview.displayName = 'EndpointOverview'; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx index 3292f0297fa2df..e1c12ac6383a60 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx @@ -15,21 +15,39 @@ import { mockData } from './mock'; import { mockAnomalies } from '../../../common/components/ml/mock'; describe('Host Summary Component', () => { describe('rendering', () => { + const mockProps = { + anomaliesData: mockAnomalies, + data: mockData.Hosts.edges[0].node, + docValueFields: [], + endDate: '2019-06-18T06:00:00.000Z', + id: 'hostOverview', + indexNames: [], + isInDetailsSidePanel: false, + isLoadingAnomaliesData: false, + loading: false, + narrowDateRange: jest.fn(), + startDate: '2019-06-15T06:00:00.000Z', + }; + test('it renders the default Host Summary', () => { const wrapper = shallow( - + + + ); + + expect(wrapper.find('HostOverview')).toMatchSnapshot(); + }); + + test('it renders the panel view Host Summary', () => { + const panelViewProps = { + ...mockProps, + isInDetailsSidePanel: true, + }; + + const wrapper = shallow( + + ); diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index 90dc6816173289..de0d782b3ceb72 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { EuiHorizontalRule } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; @@ -27,7 +27,7 @@ import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types'; -import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page'; +import { OverviewWrapper } from '../../../common/components/page'; import { FirstLastSeenHost, FirstLastSeenHostType, @@ -35,11 +35,14 @@ import { import * as i18n from './translations'; import { EndpointOverview } from './endpoint_overview'; +import { OverviewDescriptionList } from '../../../common/components/overview_description_list'; interface HostSummaryProps { + contextID?: string; // used to provide unique draggable context when viewing in the side panel data: HostItem; docValueFields: DocValueFields[]; id: string; + isInDetailsSidePanel: boolean; loading: boolean; isLoadingAnomaliesData: boolean; indexNames: string[]; @@ -49,19 +52,15 @@ interface HostSummaryProps { narrowDateRange: NarrowDateRange; } -const getDescriptionList = (descriptionList: DescriptionList[], key: number) => ( - - - -); - export const HostOverview = React.memo( ({ anomaliesData, + contextID, data, docValueFields, endDate, id, + isInDetailsSidePanel = false, // Rather than duplicate the component, alter the structure based on it's location isLoadingAnomaliesData, indexNames, loading, @@ -77,10 +76,10 @@ export const HostOverview = React.memo( ), - [] + [contextID] ); const column: DescriptionList[] = useMemo( @@ -162,7 +161,7 @@ export const HostOverview = React.memo( (ip != null ? : getEmptyTagValue())} /> ), @@ -198,17 +197,22 @@ export const HostOverview = React.memo( }, ], ], - [data, firstColumn, getDefaultRenderer] + [contextID, data, firstColumn, getDefaultRenderer] ); return ( <> - - - - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) + + {!isInDetailsSidePanel && ( + )} + {descriptionLists.map((descriptionList, index) => ( + + ))} {loading && ( ( {data.endpoint != null ? ( <> - - + + {loading && ( fieldNames.length > 0 && fieldNames.every((fieldName) => getOr(null, fieldName, data)) ? ( @@ -52,7 +53,9 @@ export const locationRenderer = ( {index ? ',\u00A0' : ''} @@ -71,13 +74,16 @@ export const dateRenderer = (timestamp?: string | null): React.ReactElement => ( export const autonomousSystemRenderer = ( as: AutonomousSystem, - flowTarget: FlowTarget + flowTarget: FlowTarget, + contextID?: string ): React.ReactElement => as && as.organization && as.organization.name && as.number ? ( @@ -85,7 +91,9 @@ export const autonomousSystemRenderer = ( {'/'} @@ -96,12 +104,14 @@ export const autonomousSystemRenderer = ( ); interface HostIdRendererTypes { + contextID?: string; host: HostEcs; ipFilter?: string; noLink?: boolean; } export const hostIdRenderer = ({ + contextID, host, ipFilter, noLink, @@ -110,7 +120,9 @@ export const hostIdRenderer = ({ <> {host.name && host.name[0] != null ? ( @@ -128,14 +140,20 @@ export const hostIdRenderer = ({ getEmptyTagValue() ); -export const hostNameRenderer = (host?: HostEcs, ipFilter?: string): React.ReactElement => +export const hostNameRenderer = ( + host?: HostEcs, + ipFilter?: string, + contextID?: string +): React.ReactElement => host && host.name && host.name[0] && host.ip && (!(ipFilter != null) || host.ip.includes(ipFilter)) ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index a3ac543ac6682a..e1331f1b496bac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -6,9 +6,11 @@ */ import { isArray, isEmpty, isString, uniq } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useContext } from 'react'; +import { useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; import { DragEffects, DraggableWrapper, @@ -16,13 +18,21 @@ import { import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; import { Content } from '../../../common/components/draggables'; import { getOrEmptyTagFromValue } from '../../../common/components/empty_value'; -import { NetworkDetailsLink } from '../../../common/components/links'; import { parseQueryValue } from '../../../timelines/components/timeline/body/renderers/parse_query_value'; import { DataProvider, IS_OPERATOR, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { + TimelineExpandedDetailType, + TimelineId, + TimelineTabs, +} from '../../../../common/types/timeline'; +import { activeTimeline } from '../../containers/active_timeline_context'; +import { timelineActions } from '../../store/timeline'; +import { StatefulEventContext } from '../timeline/body/events/stateful_event_context'; +import { LinkAnchor } from '../../../common/components/links'; const getUniqueId = ({ contextId, @@ -128,22 +138,52 @@ const AddressLinksItemComponent: React.FC = ({ fieldName, truncate, }) => { - const key = useMemo( - () => - `address-links-draggable-wrapper-${getUniqueId({ - contextId, - eventId, - fieldName, - address, - })}`, - [address, contextId, eventId, fieldName] - ); + const key = `address-links-draggable-wrapper-${getUniqueId({ + contextId, + eventId, + fieldName, + address, + })}`; const dataProviderProp = useMemo( () => getDataProvider({ contextId, eventId, fieldName, address }), [address, contextId, eventId, fieldName] ); + const dispatch = useDispatch(); + const eventContext = useContext(StatefulEventContext); + + const openNetworkDetailsSidePanel = useCallback( + (e) => { + e.preventDefault(); + if (address && eventContext?.timelineID && eventContext?.tabType) { + const { tabType, timelineID } = eventContext; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'networkDetail', + params: { + ip: address, + flowTarget: fieldName.includes(FlowTarget.destination) + ? FlowTarget.destination + : FlowTarget.source, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + timelineId: timelineID, + }) + ); + + if (timelineID === TimelineId.active && tabType === TimelineTabs.query) { + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + } + } + }, + [dispatch, eventContext, address, fieldName] + ); + const render = useCallback( (_props, _provided, snapshot) => snapshot.isDragging ? ( @@ -152,10 +192,16 @@ const AddressLinksItemComponent: React.FC = ({ ) : ( - + + {address} + ), - [address, dataProviderProp, fieldName] + [address, dataProviderProp, openNetworkDetailsSidePanel, fieldName] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index b9a0df63e19af4..cde1b705be98e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -294,7 +294,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -397,7 +397,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -500,7 +500,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -601,7 +601,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -740,7 +740,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -868,7 +868,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [ { $state: { @@ -1012,7 +1012,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], @@ -1115,7 +1115,7 @@ describe('helpers', () => { eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 76b53adc872e81..5581ea4e5c1653 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -46,10 +46,11 @@ const ToggleEventDetailsButtonComponent: React.FC const handleClick = useCallback(() => { dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + panelView: 'eventDetail', tabType: TimelineTabs.notes, timelineId, - event: { + params: { eventId, indexName: existingIndexNames.join(','), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000000..124c8012fd533c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1029 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set 1`] = ` + +`; + +exports[`Details Panel Component DetailsPanel: rendering it should not render the DetailsPanel if no expanded detail has been set in the reducer 1`] = ` + +`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Details Panel when the panelView is set and the associated params are set 1`] = ` +.c0 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + + + + + + +
+ +
+ +
+
+ +
+ + + +
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set 1`] = ` +Array [ + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + +
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
, +] +`; + +exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set 1`] = `null`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 159745c5a3f861..6e8238dfe4b25e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -21,7 +21,7 @@ import { import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; -import { TimelineExpandedEventType, TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineTabs } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, @@ -36,7 +36,7 @@ export type HandleOnEventClosed = () => void; interface Props { browserFields: BrowserFields; detailsData: TimelineEventsDetailsItem[] | null; - event: TimelineExpandedEventType; + event: { eventId: string; indexName: string }; isAlert: boolean; loading: boolean; messageHeight?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx new file mode 100644 index 00000000000000..d8b9e7121f60db --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -0,0 +1,109 @@ +/* + * 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 { some } from 'lodash/fp'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; + +import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; +import { useTimelineEventsDetails } from '../../../containers/details'; +import { TimelineTabs } from '../../../../../common/types/timeline'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow: hidden; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow: hidden; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +interface EventDetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + expandedEvent: { eventId: string; indexName: string }; + handleOnEventClosed: () => void; + isFlyoutView?: boolean; + tabType: TimelineTabs; + timelineId: string; +} + +const EventDetailsPanelComponent: React.FC = ({ + browserFields, + docValueFields, + expandedEvent, + handleOnEventClosed, + isFlyoutView, + tabType, + timelineId, +}) => { + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent.indexName ?? '', + eventId: expandedEvent.eventId ?? '', + skip: !expandedEvent.eventId, + }); + + const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData); + + if (!expandedEvent?.eventId) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + ) : ( + <> + + + + + ); +}; + +export const EventDetailsPanel = React.memo( + EventDetailsPanelComponent, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.docValueFields, nextProps.docValueFields) && + prevProps.timelineId === nextProps.timelineId +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts similarity index 77% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts index 234f3ac49e64d0..2910e04747e397 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts @@ -14,13 +14,6 @@ export const MESSAGE = i18n.translate( } ); -export const COPY_TO_CLIPBOARD = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip', - { - defaultMessage: 'Copy to Clipboard', - } -); - export const CLOSE = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel', { @@ -28,13 +21,6 @@ export const CLOSE = i18n.translate( } ); -export const EVENT = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle', - { - defaultMessage: 'Event', - } -); - export const EVENT_DETAILS_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.placeholder', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx new file mode 100644 index 00000000000000..4e101e29bb4841 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTitle } from '@elastic/eui'; +import { HostDetailsLink } from '../../../../common/components/links'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { HostOverview } from '../../../../overview/components/host_overview'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { HostItem } from '../../../../../common/search_strategy'; +import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { hostToCriteria } from '../../../../common/components/ml/criteria/host_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { HostOverviewByNameQuery } from '../../../../hosts/containers/hosts/details'; + +interface ExpandableHostProps { + hostName: string; +} + +export const ExpandableHostDetailsTitle = ({ hostName }: ExpandableHostProps) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.title', { + defaultMessage: 'Host details', + })} + {`: ${hostName}`} +

+
+); + +export const ExpandableHostDetailsPageLink = ({ hostName }: ExpandableHostProps) => ( + + {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.hostDetailsPageLink', { + defaultMessage: 'View details page', + })} + +); + +export const ExpandableHostDetails = ({ + contextID, + hostName, +}: ExpandableHostProps & { contextID: string }) => { + const { to, from, isInitializing } = useGlobalTime(); + const { docValueFields, selectedPatterns } = useSourcererScope(); + return ( + + {({ hostOverview, loading, id }) => ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx new file mode 100644 index 00000000000000..39064cda160011 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx @@ -0,0 +1,116 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { + ExpandableHostDetails, + ExpandableHostDetailsPageLink, + ExpandableHostDetailsTitle, +} from './expandable_host'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface HostDetailsProps { + contextID: string; + expandedHost: { hostName: string }; + handleOnHostClosed: () => void; + isFlyoutView?: boolean; +} + +export const HostDetailsPanel: React.FC = React.memo( + ({ contextID, expandedHost, handleOnHostClosed, isFlyoutView }) => { + const { hostName } = expandedHost; + + if (!hostName) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx new file mode 100644 index 00000000000000..71ab7f01ddd546 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -0,0 +1,204 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; + +import '../../../common/mock/match_media'; +import { + apolloClientObservable, + mockGlobalState, + TestProviders, + SUB_PLUGINS_REDUCER, + kibanaObservable, + createSecuritySolutionStorageMock, +} from '../../../common/mock'; +import { createStore, State } from '../../../common/store'; +import { DetailsPanel } from './index'; +import { TimelineExpandedDetail, TimelineTabs } from '../../../../common/types/timeline'; +import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; + +describe('Details Panel Component', () => { + const state: State = { ...mockGlobalState }; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + + const dataLessExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: {}, + }, + }; + + const hostExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: { + hostName: 'woohoo!', + }, + }, + }; + + const networkExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'networkDetail', + params: { + ip: 'woohoo!', + flowTarget: FlowTarget.source, + }, + }, + }; + + const eventExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'eventDetail', + params: { + eventId: 'my-id', + indexName: 'my-index', + }, + }, + }; + + const mockProps = { + browserFields: {}, + docValueFields: [], + handleOnPanelClosed: jest.fn(), + isFlyoutView: false, + tabType: TimelineTabs.query, + timelineId: 'test', + }; + + describe('DetailsPanel: rendering', () => { + beforeEach(() => { + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set', () => { + state.timeline.timelineById.test.expandedDetail = dataLessExpandedDetail as TimelineExpandedDetail; // Casting as the dataless doesn't meet the actual type requirements + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:EventDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = eventExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Details Panel when the panelView is set and the associated params are set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set', () => { + const currentProps = { ...mockProps, isFlyoutView: true }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline:details-panel:flyout"]')).toMatchSnapshot(); + }); + + test('it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('EventDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:HostDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = hostExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('HostDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:NetworkDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = networkExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('NetworkDetails')).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx new file mode 100644 index 00000000000000..0482491562f57b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -0,0 +1,120 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { EuiFlyout } from '@elastic/eui'; +import styled from 'styled-components'; +import { timelineActions, timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { BrowserFields, DocValueFields } from '../../../common/containers/source'; +import { TimelineTabs } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { EventDetailsPanel } from './event_details'; +import { HostDetailsPanel } from './host_details'; +import { NetworkDetailsPanel } from './network_details'; + +const StyledEuiFlyout = styled(EuiFlyout)` + z-index: ${({ theme }) => theme.eui.euiZLevel7}; +`; + +interface DetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + handleOnPanelClosed?: () => void; + isFlyoutView?: boolean; + tabType?: TimelineTabs; + timelineId: string; +} + +/** + * This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages. + * To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used + * `tabType` defaults to query and `handleOnPanelClosed` defaults to unsetting the default query tab which is used for the flyout panel + */ +export const DetailsPanel = React.memo( + ({ + browserFields, + docValueFields, + handleOnPanelClosed, + isFlyoutView, + tabType, + timelineId, + }: DetailsPanelProps) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const expandedDetail = useDeepEqualSelector((state) => { + return (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail; + }); + + // To be used primarily in the flyout scenario where we don't want to maintain the tabType + const defaultOnPanelClose = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ timelineId })); + }, [dispatch, timelineId]); + + const activeTab = tabType ?? TimelineTabs.query; + const closePanel = useCallback(() => { + if (handleOnPanelClosed) handleOnPanelClosed(); + else defaultOnPanelClose(); + }, [defaultOnPanelClose, handleOnPanelClosed]); + + if (!expandedDetail) return null; + + const currentTabDetail = expandedDetail[activeTab]; + + if (!currentTabDetail?.panelView) return null; + + let visiblePanel = null; // store in variable to make return statement more readable + const contextID = `${timelineId}-${activeTab}`; + + if (currentTabDetail?.panelView === 'eventDetail' && currentTabDetail?.params?.eventId) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'hostDetail' && currentTabDetail?.params?.hostName) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'networkDetail' && currentTabDetail?.params?.ip) { + visiblePanel = ( + + ); + } + + return isFlyoutView ? ( + + {visiblePanel} + + ) : ( + visiblePanel + ); + } +); + +DetailsPanel.displayName = 'DetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx new file mode 100644 index 00000000000000..b12b575681acf2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx @@ -0,0 +1,134 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { NetworkDetailsLink } from '../../../../common/components/links'; +import { IpOverview } from '../../../../network/components/details'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { networkToCriteria } from '../../../../common/components/ml/criteria/network_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { useKibana } from '../../../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../../../common/lib/keury'; +import { inputsSelectors } from '../../../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { OverviewEmpty } from '../../../../overview/components/overview_empty'; +import { esQuery } from '../../../../../../../../src/plugins/data/public'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useNetworkDetails } from '../../../../network/containers/details'; +import { networkModel } from '../../../../network/store'; +import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data'; + +interface ExpandableNetworkProps { + expandedNetwork: { ip: string; flowTarget: FlowTarget }; +} + +export const ExpandableNetworkDetailsTitle = ({ ip }: { ip: string }) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.networkDetails.title', { + defaultMessage: 'Network details', + })} + {`: ${ip}`} +

+
+); + +export const ExpandableNetworkDetailsPageLink = ({ + expandedNetwork: { ip, flowTarget }, +}: ExpandableNetworkProps) => ( + + {i18n.translate( + 'xpack.securitySolution.timeline.sidePanel.networkDetails.networkDetailsPageLink', + { + defaultMessage: 'View details page', + } + )} + +); + +export const ExpandableNetworkDetails = ({ + contextID, + expandedNetwork, +}: ExpandableNetworkProps & { contextID: string }) => { + const { ip, flowTarget } = expandedNetwork; + const dispatch = useDispatch(); + const { to, from, isInitializing } = useGlobalTime(); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const type = networkModel.NetworkType.details; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + const { + services: { uiSettings }, + } = useKibana(); + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }); + + const [loading, { id, networkDetails }] = useNetworkDetails({ + docValueFields, + skip: isInitializing, + filterQuery, + indexNames: selectedPatterns, + ip, + }); + + const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({ + criteriaFields: networkToCriteria(ip, flowTarget), + startDate: from, + endDate: to, + skip: isInitializing, + }); + + return indicesExist ? ( + + ) : ( + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx new file mode 100644 index 00000000000000..e05c9435fc456f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx @@ -0,0 +1,113 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { + ExpandableNetworkDetailsTitle, + ExpandableNetworkDetailsPageLink, + ExpandableNetworkDetails, +} from './expandable_network'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface NetworkDetailsProps { + contextID: string; + expandedNetwork: { ip: string; flowTarget: FlowTarget }; + handleOnNetworkClosed: () => void; + isFlyoutView?: boolean; +} + +export const NetworkDetailsPanel = React.memo( + ({ contextID, expandedNetwork, handleOnNetworkClosed, isFlyoutView }: NetworkDetailsProps) => { + const { ip } = expandedNetwork; + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 1ee5e39dfaa26c..16e2b28a120d76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -30,10 +30,9 @@ describe('Actions', () => { ariaRowindex={2} checked={false} columnValues={'abc def'} - expanded={false} eventId="abc" loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={true} /> @@ -52,9 +51,8 @@ describe('Actions', () => { checked={false} columnValues={'abc def'} eventId="abc" - expanded={false} loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={false} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 2bbf793b9c78f5..9ce27aa936783d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -20,10 +20,9 @@ interface Props { columnValues: string; checked: boolean; onRowSelected: OnRowSelected; - expanded: boolean; eventId: string; loadingEventIds: Readonly; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; showCheckboxes: boolean; } @@ -33,10 +32,9 @@ const ActionsComponent: React.FC = ({ additionalActions, checked, columnValues, - expanded, eventId, loadingEventIds, - onEventToggled, + onEventDetailsPanelOpened, onRowSelected, showCheckboxes, }) => { @@ -78,9 +76,8 @@ const ActionsComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 9be338e6b44b31..abdfda3272d6ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -51,7 +51,7 @@ describe('EventColumnView', () => { loading: false, loadingEventIds: [], notesCount: 0, - onEventToggled: jest.fn(), + onEventDetailsPanelOpened: jest.fn(), onPinEvent: jest.fn(), onRowSelected: jest.fn(), onUnPinEvent: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 0afb31984ee8e0..9d7b76af25a59d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -42,12 +42,11 @@ interface Props { data: TimelineNonEcsData[]; ecsData: Ecs; eventIdToNoteIds: Readonly>; - expanded: boolean; isEventPinned: boolean; isEventViewer?: boolean; loadingEventIds: Readonly; notesCount: number; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; @@ -74,12 +73,11 @@ export const EventColumnView = React.memo( data, ecsData, eventIdToNoteIds, - expanded, isEventPinned = false, isEventViewer = false, loadingEventIds, notesCount, - onEventToggled, + onEventDetailsPanelOpened, onPinEvent, onRowSelected, onUnPinEvent, @@ -220,14 +218,12 @@ export const EventColumnView = React.memo( checked={Object.keys(selectedEventIds).includes(id)} columnValues={columnValues} onRowSelected={onRowSelected} - expanded={expanded} data-test-subj="actions" eventId={id} loadingEventIds={loadingEventIds} - onEventToggled={onEventToggled} + onEventDetailsPanelOpened={onEventDetailsPanelOpened} showCheckboxes={showCheckboxes} /> - = ({ }) => { const trGroupRef = useRef(null); const dispatch = useDispatch(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ timelineID: timelineId, tabType }); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => - (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[ - tabType ?? TimelineTabs.query - ] ?? {} + const expandedDetail = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {} ); + const hostName = useMemo(() => { + const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); + return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null; + }, [event?.data]); + + const hostIPAddresses = useMemo(() => { + const ipList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }); + return ipList; + }, [event?.data]); + + const activeTab = tabType ?? TimelineTabs.query; + const activeExpandedDetail = expandedDetail[activeTab]; + + const isDetailPanelExpanded: boolean = + (activeExpandedDetail?.panelView === 'eventDetail' && + activeExpandedDetail?.params?.eventId === event._id) || + (activeExpandedDetail?.panelView === 'hostDetail' && + activeExpandedDetail?.params?.hostName === hostName) || + (activeExpandedDetail?.panelView === 'networkDetail' && + activeExpandedDetail?.params?.ip && + hostIPAddresses?.includes(activeExpandedDetail?.params?.ip)) || + false; + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); const noteIds: string[] = eventIdToNoteIds[event._id] || emptyNotes; - const isExpanded = useMemo(() => expandedEvent && expandedEvent.eventId === event._id, [ - event._id, - expandedEvent, - ]); const notes: TimelineResultNote[] = useMemo( () => @@ -151,23 +175,28 @@ const StatefulEventComponent: React.FC = ({ [dispatch, timelineId] ); - const handleOnEventToggled = useCallback(() => { + const handleOnEventDetailPanelOpened = useCallback(() => { const eventId = event._id; const indexName = event._index!; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId, + indexName, + }, + }; + dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, tabType, timelineId, - event: { - eventId, - indexName, - }, }) ); if (timelineId === TimelineId.active && tabType === TimelineTabs.query) { - activeTimeline.toggleExpandedEvent({ eventId, indexName }); + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); } }, [dispatch, event._id, event._index, tabType, timelineId]); @@ -207,63 +236,64 @@ const StatefulEventComponent: React.FC = ({ ); return ( - - + + + - - - - + + + + - {RowRendererContent} - - + {RowRendererContent} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx new file mode 100644 index 00000000000000..34abc06371aacf --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TimelineTabs } from '../../../../../../common/types/timeline'; + +interface StatefulEventContext { + tabType: TimelineTabs | undefined; + timelineID: string; +} + +// This context is available to all children of the stateful_event component where the provider is currently set +export const StatefulEventContext = React.createContext(null); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 7decff82707367..723e4c3de5c275 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -240,14 +240,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'query', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -263,14 +264,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'pinned', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -286,14 +288,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'notes', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8aa1425bbe52d7..4df6eb16ccb623 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -60,6 +60,10 @@ const EXTRA_WIDTH = 4; // px export type StatefulBodyProps = OwnProps & PropsFromRedux; +/** + * The Body component is used everywhere timeline is used within the security application. It is the highest level component + * that is shared across all implementations of the timeline. + */ export const BodyComponent = React.memo( ({ activePage, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx index e97738d95e43fa..9d716f8325cbca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx @@ -243,7 +243,7 @@ describe('Events', () => { expect(wrapper.find('[data-test-subj="truncatable-message"]').exists()).toEqual(false); }); - test('it renders a hyperlink to the hosts details page when fieldName is host.name, and a hostname is provided', () => { + test('it renders a button to open the hosts details panel when fieldName is host.name, and a hostname is provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(true); }); - test('it does NOT render a hyperlink to the hosts details page when fieldName is host.name, but a hostname is NOT provided', () => { + test('it does NOT render a button to open the hosts details panel when fieldName is host.name, but a hostname is NOT provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(false); }); test('it renders placeholder text when fieldName is host.name, but a hostname is NOT provided', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index 50ed97d5fd8b65..c57cfce3cebe67 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useContext } from 'react'; +import { useDispatch } from 'react-redux'; import { isString } from 'lodash/fp'; - +import { LinkAnchor } from '../../../../../common/components/links'; +import { + TimelineId, + TimelineTabs, + TimelineExpandedDetailType, +} from '../../../../../../common/types/timeline'; import { DefaultDraggable } from '../../../../../common/components/draggables'; import { getEmptyTagValue } from '../../../../../common/components/empty_value'; -import { HostDetailsLink } from '../../../../../common/components/links'; import { TruncatableText } from '../../../../../common/components/truncatable_text'; +import { StatefulEventContext } from '../events/stateful_event_context'; +import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { timelineActions } from '../../../../store/timeline'; interface Props { contextId: string; @@ -21,18 +29,48 @@ interface Props { } const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { - const hostname = `${value}`; + const dispatch = useDispatch(); + const eventContext = useContext(StatefulEventContext); + const hostName = `${value}`; + + const openHostDetailsSidePanel = useCallback( + (e) => { + e.preventDefault(); + if (hostName && eventContext?.tabType && eventContext?.timelineID) { + const { timelineID, tabType } = eventContext; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'hostDetail', + params: { + hostName, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + timelineId: timelineID, + tabType, + }) + ); + + if (timelineID === TimelineId.active && tabType === TimelineTabs.query) { + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + } + } + }, + [dispatch, eventContext, hostName] + ); - return isString(value) && hostname.length > 0 ? ( + return isString(value) && hostName.length > 0 ? ( - - {value} - + + {hostName} + ) : ( getEmptyTagValue() diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx deleted file mode 100644 index 6b8381c54de018..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ /dev/null @@ -1,85 +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 { some } from 'lodash/fp'; -import { EuiSpacer } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import deepEqual from 'fast-deep-equal'; - -import { BrowserFields, DocValueFields } from '../../../common/containers/source'; -import { - ExpandableEvent, - ExpandableEventTitle, - HandleOnEventClosed, -} from '../../../timelines/components/timeline/expandable_event'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { useTimelineEventsDetails } from '../../containers/details'; -import { timelineSelectors } from '../../store/timeline'; -import { timelineDefaults } from '../../store/timeline/defaults'; -import { TimelineTabs } from '../../../../common/types/timeline'; - -interface EventDetailsProps { - browserFields: BrowserFields; - docValueFields: DocValueFields[]; - tabType: TimelineTabs; - timelineId: string; - handleOnEventClosed?: HandleOnEventClosed; -} - -const EventDetailsComponent: React.FC = ({ - browserFields, - docValueFields, - tabType, - timelineId, - handleOnEventClosed, -}) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[tabType] ?? {} - ); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: expandedEvent.indexName!, - eventId: expandedEvent.eventId!, - skip: !expandedEvent.eventId, - }); - - const isAlert = useMemo( - () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), - [detailsData] - ); - - return ( - <> - - - - - ); -}; - -export const EventDetails = React.memo( - EventDetailsComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId && - prevProps.handleOnEventClosed === nextProps.handleOnEventClosed -); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index c37fc93e33b08e..09b32b8f6140d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,7 +18,7 @@ import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineTabs, TimelineId } from '../../../../common/types/timeline'; +import { TimelineType, TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; @@ -69,9 +69,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { id: timelineId, columns: defaultHeaders, indexNames: selectedPatterns, - expandedEvent: { - [TimelineTabs.query]: activeTimeline.getExpandedEvent(), - }, + expandedDetail: activeTimeline.getExpandedDetail(), show: false, }) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index b083b346668448..0d32e790dab508 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -31,8 +31,8 @@ import { CREATED_BY, NOTES } from '../../notes/translations'; import { PARTICIPANTS } from '../../../../cases/translations'; import { NotePreviews } from '../../open_timeline/note_previews'; import { TimelineResultNote } from '../../open_timeline/types'; -import { EventDetails } from '../event_details'; import { getTimelineNoteSelector } from './selectors'; +import { DetailsPanel } from '../../side_panel'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -125,7 +125,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); const { createdBy, - expandedEvent, + expandedDetail, eventIdToNoteIds, noteIds, status: timelineStatus, @@ -162,22 +162,22 @@ const NotesTabContentComponent: React.FC = ({ timelineId } [dispatch, timelineId] ); - const handleOnEventClosed = useCallback(() => { - dispatch(timelineActions.toggleExpandedEvent({ tabType: TimelineTabs.notes, timelineId })); + const handleOnPanelClosed = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ tabType: TimelineTabs.notes, timelineId })); }, [dispatch, timelineId]); - const EventDetailsContent = useMemo( + const DetailsPanelContent = useMemo( () => - expandedEvent?.eventId != null ? ( - ) : null, - [browserFields, docValueFields, expandedEvent, handleOnEventClosed, timelineId] + [browserFields, docValueFields, expandedDetail, handleOnPanelClosed, timelineId] ); const SidebarContent = useMemo( @@ -216,7 +216,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } - {EventDetailsContent ?? SidebarContent} + {DetailsPanelContent ?? SidebarContent} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts index 84e39e5481afde..bc0317f4c42822 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts @@ -13,7 +13,7 @@ export const getTimelineNoteSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => { return { createdBy: timeline.createdBy, - expandedEvent: timeline.expandedEvent?.notes ?? {}, + expandedDetail: timeline.expandedDetail ?? {}, eventIdToNoteIds: timeline?.eventIdToNoteIds ?? {}, noteIds: timeline.noteIds, status: timeline.status, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap index f5064ba66cf2fa..e55c1cc8f0af38 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -135,7 +135,7 @@ In other use cases the message field can be used to concatenate different values } onEventClosed={[MockFunction]} pinnedEventIds={Object {}} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index 56d53c5fecb967..2107969df22b8e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -96,7 +96,7 @@ describe('PinnedTabContent', () => { itemsPerPageOptions: [5, 10, 20], sort, pinnedEventIds: {}, - showEventDetails: false, + showExpandedDetails: false, onEventClosed: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index 98cc130a38de33..68461a7234d091 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -25,11 +25,11 @@ import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { TimelineModel } from '../../../store/timeline/model'; -import { EventDetails } from '../event_details'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { State } from '../../../../common/store'; import { calculateTotalPages } from '../helpers'; import { TimelineTabs } from '../../../../../common/types/timeline'; +import { DetailsPanel } from '../../side_panel'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -90,7 +90,7 @@ export const PinnedTabContentComponent: React.FC = ({ itemsPerPageOptions, pinnedEventIds, onEventClosed, - showEventDetails, + showExpandedDetails, sort, }) => { const { browserFields, docValueFields, loading: loadingSourcerer } = useSourcererScope( @@ -169,7 +169,7 @@ export const PinnedTabContentComponent: React.FC = ({ timerangeKind: undefined, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.pinned, timelineId }); }, [timelineId, onEventClosed]); @@ -217,16 +217,16 @@ export const PinnedTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -242,7 +242,7 @@ const makeMapStateToProps = () => { const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; const { columns, - expandedEvent, + expandedDetail, itemsPerPage, itemsPerPageOptions, pinnedEventIds, @@ -255,7 +255,8 @@ const makeMapStateToProps = () => { itemsPerPage, itemsPerPageOptions, pinnedEventIds, - showEventDetails: !!expandedEvent[TimelineTabs.pinned]?.eventId, + showExpandedDetails: + !!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView, sort, }; }; @@ -263,8 +264,8 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -278,7 +279,7 @@ const PinnedTabContent = connector( (prevProps, nextProps) => prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.onEventClosed === nextProps.onEventClosed && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.timelineId === nextProps.timelineId && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index 4fbf7788d91223..0688a10b31eefb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -262,7 +262,7 @@ In other use cases the message field can be used to concatenate different values } end="2018-03-24T03:33:52.253Z" eventType="all" - expandedEvent={Object {}} + expandedDetail={Object {}} filters={Array []} isLive={false} itemsPerPage={5} @@ -278,7 +278,7 @@ In other use cases the message field can be used to concatenate different values onEventClosed={[MockFunction]} show={true} showCallOutUnauthorizedMsg={false} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 882c0c90973b37..c7d27da64c6506 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -96,9 +96,8 @@ describe('Timeline', () => { columns: defaultHeaders, dataProviders: mockDataProviders, end: endDate, - expandedEvent: {}, eventType: 'all', - showEventDetails: false, + expandedDetail: {}, filters: [], timelineId: TimelineId.test, isLive: false, @@ -108,6 +107,7 @@ describe('Timeline', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, + showExpandedDetails: false, sort, start: startDate, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 25acd489169443..c61be4951db76f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -46,12 +46,12 @@ import { timelineDefaults } from '../../../../timelines/store/timeline/defaults' import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; -import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { HideShowContainer } from '../styles'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; +import { DetailsPanel } from '../../side_panel'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -139,7 +139,7 @@ export const QueryTabContentComponent: React.FC = ({ dataProviders, end, eventType, - expandedEvent, + expandedDetail, filters, timelineId, isLive, @@ -150,7 +150,7 @@ export const QueryTabContentComponent: React.FC = ({ onEventClosed, show, showCallOutUnauthorizedMsg, - showEventDetails, + showExpandedDetails, start, status, sort, @@ -245,16 +245,17 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.query, timelineId }); - if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent({ - eventId: expandedEvent.eventId!, - indexName: expandedEvent.indexName!, - }); + if ( + expandedDetail[TimelineTabs.query]?.panelView && + timelineId === TimelineId.active && + showExpandedDetails + ) { + activeTimeline.toggleExpandedDetail({}); } - }, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]); + }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); useEffect(() => { setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); @@ -350,16 +351,16 @@ export const QueryTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -382,7 +383,7 @@ const makeMapStateToProps = () => { columns, dataProviders, eventType, - expandedEvent, + expandedDetail, filters, itemsPerPage, itemsPerPageOptions, @@ -406,7 +407,7 @@ const makeMapStateToProps = () => { dataProviders, eventType: eventType ?? 'raw', end: input.timerange.to, - expandedEvent: expandedEvent[TimelineTabs.query] ?? {}, + expandedDetail, filters: timelineFilter, timelineId, isLive: input.policy.kind === 'interval', @@ -415,8 +416,9 @@ const makeMapStateToProps = () => { kqlMode, kqlQueryExpression, showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - showEventDetails: !!expandedEvent[TimelineTabs.query]?.eventId, show, + showExpandedDetails: + !!expandedDetail[TimelineTabs.query] && !!expandedDetail[TimelineTabs.query]?.panelView, sort, start: input.timerange.from, status, @@ -437,8 +439,8 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) ); }, - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -460,7 +462,7 @@ const QueryTabContent = connector( prevProps.onEventClosed === nextProps.onEventClosed && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.status === nextProps.status && prevProps.timelineId === nextProps.timelineId && prevProps.updateEventTypeAndIndexesName === nextProps.updateEventTypeAndIndexesName && diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts index 190cf53689ec08..93e53fa544bbc0 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { TimelineExpandedEventType } from '../../../common/types/timeline'; +import { + TimelineExpandedDetail, + TimelineExpandedDetailType, + TimelineTabs, +} from '../../../common/types/timeline'; import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; import { TimelineArgs } from '.'; @@ -22,7 +26,7 @@ import { TimelineArgs } from '.'; class ActiveTimelineEvents { private _activePage: number = 0; - private _expandedEvent: TimelineExpandedEventType = {}; + private _expandedDetail: TimelineExpandedDetail = {}; private _pageName: string = ''; private _request: TimelineEventsAllRequestOptions | null = null; private _response: TimelineArgs | null = null; @@ -35,20 +39,40 @@ class ActiveTimelineEvents { this._activePage = activePage; } - getExpandedEvent() { - return this._expandedEvent; + getExpandedDetail() { + return this._expandedDetail; } - toggleExpandedEvent(expandedEvent: TimelineExpandedEventType) { - if (expandedEvent.eventId === this._expandedEvent.eventId) { - this._expandedEvent = {}; + toggleExpandedDetail(expandedDetail: TimelineExpandedDetailType) { + const queryTab = TimelineTabs.query; + const currentExpandedDetail = this._expandedDetail[queryTab]; + let isSameExpandedDetail; + + // Check if the stored details matches the incoming detail + if (currentExpandedDetail?.panelView === 'eventDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'eventDetail' && + expandedDetail?.params?.eventId === currentExpandedDetail?.params?.eventId; + } else if (currentExpandedDetail?.panelView === 'hostDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'hostDetail' && + expandedDetail?.params?.hostName === currentExpandedDetail?.params?.hostName; + } else if (currentExpandedDetail?.panelView === 'networkDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'networkDetail' && + expandedDetail?.params?.ip === currentExpandedDetail?.params?.ip; + } + + // if so, unset it, otherwise set it + if (isSameExpandedDetail) { + this._expandedDetail = {}; } else { - this._expandedEvent = expandedEvent; + this._expandedDetail = { [queryTab]: { ...expandedDetail } }; } } - setExpandedEvent(expandedEvent: TimelineExpandedEventType) { - this._expandedEvent = expandedEvent; + setExpandedDetail(expandedDetail: TimelineExpandedDetail) { + this._expandedDetail = expandedDetail; } getPageName() { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 57815a6d6bcd71..0d53d01fa71315 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -113,7 +113,7 @@ export const useTimelineEvents = ({ clearSignalsState(); if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setActivePage(newActivePage); } @@ -178,7 +178,7 @@ export const useTimelineEvents = ({ updatedAt: Date.now(), }; if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setPageName(pageName); activeTimeline.setRequest(request); activeTimeline.setResponse(newTimelineResponse); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index a38d81a68d1bf3..c9e3c8305a30de 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -20,10 +20,10 @@ import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEventType, + TimelineExpandedDetail, + TimelineExpandedDetailType, TimelineTypeLiteral, RowRendererId, - TimelineExpandedEvent, TimelineTabs, } from '../../../../common/types/timeline'; import { InsertTimeline } from './types'; @@ -38,12 +38,12 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); -export interface ToggleExpandedEvent { - event?: TimelineExpandedEventType; +export type ToggleDetailPanel = TimelineExpandedDetailType & { tabType?: TimelineTabs; timelineId: string; -} -export const toggleExpandedEvent = actionCreator('TOGGLE_EXPANDED_EVENT'); +}; + +export const toggleDetailPanel = actionCreator('TOGGLE_DETAIL_PANEL'); export const upsertColumn = actionCreator<{ column: ColumnHeaderOptions; @@ -67,7 +67,7 @@ export interface TimelineInput { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; columns: ColumnHeaderOptions[]; itemsPerPage?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index aaaf369f7bd5c8..44a5c05e398f16 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -25,7 +25,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick { description: '', eventIdToNoteIds: {}, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, excludedRowRendererIds: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 584d270d8bea4f..3d92397f4ab507 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -82,7 +82,7 @@ describe('epicLocalStorage', () => { dataProviders: mockDataProviders, end: endDate, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, filters: [], isLive: false, itemsPerPage: 5, @@ -91,7 +91,7 @@ describe('epicLocalStorage', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, - showEventDetails: false, + showExpandedDetails: false, start: startDate, status: TimelineStatus.active, sort, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index d5d60857abb9a5..864e52fc377a0c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -8,6 +8,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import uuid from 'uuid'; +import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; @@ -24,12 +25,13 @@ import { SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineTypeLiteral, TimelineType, RowRendererId, TimelineStatus, TimelineId, + TimelineTabs, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -144,7 +146,7 @@ export const addTimelineToStore = ({ }: AddTimelineParams): TimelineById => { if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { activeTimeline.setActivePage(0); - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); } return { ...timelineById, @@ -171,7 +173,7 @@ interface AddNewTimelineParams { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; id: string; itemsPerPage?: number; @@ -192,7 +194,7 @@ export const addNewTimeline = ({ dataProviders = [], dateRange: maybeDateRange, excludedRowRendererIds = [], - expandedEvent = {}, + expandedDetail = {}, filters = timelineDefaults.filters, id, itemsPerPage = timelineDefaults.itemsPerPage, @@ -221,7 +223,7 @@ export const addNewTimeline = ({ columns, dataProviders, dateRange, - expandedEvent, + expandedDetail, excludedRowRendererIds, filters, itemsPerPage, @@ -1431,3 +1433,21 @@ export const updateExcludedRowRenderersIds = ({ }, }; }; + +export const updateTimelineDetailsPanel = (action: ToggleDetailPanel) => { + const { tabType } = action; + + const panelViewOptions = new Set(['eventDetail', 'hostDetail', 'networkDetail']); + const expandedTabType = tabType ?? TimelineTabs.query; + + return action.panelView && panelViewOptions.has(action.panelView) + ? { + [expandedTabType]: { + params: action.params ? { ...action.params } : {}, + panelView: action.panelView, + }, + } + : { + [expandedTabType]: {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index cc9b47383e9c95..e5036efd41df4d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -14,7 +14,7 @@ import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline' import { SerializedFilterQuery } from '../../../common/store/types'; import type { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineType, TimelineStatus, RowRendererId, @@ -63,7 +63,8 @@ export interface TimelineModel { eventIdToNoteIds: Record; /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; - expandedEvent: TimelineExpandedEvent; + /** This holds the view information for the flyout when viewing timeline in a consuming view (i.e. hosts page) or the side panel in the primary timeline view */ + expandedDetail: TimelineExpandedDetail; filters?: Filter[]; /** When non-empty, display a graph view for this event */ graphEventId?: string; @@ -143,7 +144,7 @@ export type SubsetTimelineModel = Readonly< | 'eventType' | 'eventIdToNoteIds' | 'excludedRowRendererIds' - | 'expandedEvent' + | 'expandedDetail' | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 346a82ed0da1de..c4988673f49b61 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -79,7 +79,7 @@ const basicTimeline: TimelineModel = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], id: 'foo', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 791100a8b9e2a9..7271eafa14863d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -35,7 +35,7 @@ import { showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, - toggleExpandedEvent, + toggleDetailPanel, unPinEvent, updateAutoSaveMsg, updateColumns, @@ -99,11 +99,12 @@ import { updateSavedQuery, updateGraphEventId, updateFilters, + updateTimelineDetailsPanel, updateTimelineEventType, } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; -import { TimelineType, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineType } from '../../../../common/types/timeline'; export const initialTimelineState: TimelineState = { timelineById: EMPTY_TIMELINE_BY_ID, @@ -130,6 +131,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail = {}, show, columns, itemsPerPage, @@ -148,6 +150,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail, filters, id, itemsPerPage, @@ -178,22 +181,19 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) - .case(toggleExpandedEvent, (state, { tabType, timelineId, event = {} }) => { - const expandedTabType = tabType ?? TimelineTabs.query; - return { - ...state, - timelineById: { - ...state.timelineById, - [timelineId]: { - ...state.timelineById[timelineId], - expandedEvent: { - ...state.timelineById[timelineId].expandedEvent, - [expandedTabType]: event, - }, + .case(toggleDetailPanel, (state, action) => ({ + ...state, + timelineById: { + ...state.timelineById, + [action.timelineId]: { + ...state.timelineById[action.timelineId], + expandedDetail: { + ...state.timelineById[action.timelineId].expandedDetail, + ...updateTimelineDetailsPanel(action), }, }, - }; - }) + }, + })) .case(addProvider, (state, { id, provider }) => ({ ...state, timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5e0bf7501eb118..0d558f2d955385 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19512,9 +19512,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "イベント; {activePage}/{totalPages} ページ", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "アラートの詳細", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "閉じる", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "クリップボードにコピー", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "イベントの詳細", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "イベント", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "メッセージ", "xpack.securitySolution.timeline.expandableEvent.placeholder": "イベント詳細を表示するには、イベントを選択します", "xpack.securitySolution.timeline.fieldTooltip": "フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d0dbd750853a25..0f34ec19e387fe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19558,9 +19558,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "事件;第 {activePage} 页,共 {totalPages} 页", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "告警详情", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "关闭", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "复制到剪贴板", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "事件详情", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "事件", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "消息", "xpack.securitySolution.timeline.expandableEvent.placeholder": "选择事件以显示事件详情", "xpack.securitySolution.timeline.fieldTooltip": "字段", From 9fca7a9012f8d6944d40db1aee00e6adabef2c3c Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Thu, 11 Feb 2021 11:27:46 -0500 Subject: [PATCH 03/26] Add saved object docs (#90860) * iwp * add docs on saved objects * add saved object docs * Update dev_docs/key_concepts/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * Update dev_docs/tutorials/saved_objects.mdx Co-authored-by: Brandon Kobel * review updates * remove this line, support being added Co-authored-by: Brandon Kobel --- .../assets/saved_object_vs_data_indices.png | Bin 0 -> 13819 bytes dev_docs/key_concepts/saved_objects.mdx | 74 ++++++ dev_docs/tutorials/saved_objects.mdx | 250 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 dev_docs/assets/saved_object_vs_data_indices.png create mode 100644 dev_docs/key_concepts/saved_objects.mdx create mode 100644 dev_docs/tutorials/saved_objects.mdx diff --git a/dev_docs/assets/saved_object_vs_data_indices.png b/dev_docs/assets/saved_object_vs_data_indices.png new file mode 100644 index 0000000000000000000000000000000000000000..e79a5cd848db1c5f3010950ad7e224bad7fb0f02 GIT binary patch literal 13819 zcmeHuXH=70(`Xcxg9sc&M5%fZP$Ymz(a=`^Wh>Z*a9ew-<007-Gt=#}Vj=A44yaNJNC35aQJPtfxbk>C$f zj+TX06L{?HNeiBmS_)My4rdXk~SF~p_qGmn&4`w`C#&VjrB;v7hFJbt!ct?!mj zwJ@x1Mt68%n&v(LsgvH>E=}y!U~cFA1p-~$$5NF+peyHexj;8ADuTKr$sjp4DBGt~ z|000bs!)vyTQ-o?kGcG_*t`%h==Qyh+_-dBp&1ej`tAx*zGtvkmZN?YWFA=!@w7u* zz~Lbds>eZ*C!gYzj&8n6V?E2Mz&nDLWn7T$1J$pgluosGxhLMr=N3G_5HfTDLqP2< zmKYrWBqO#nl%5ov-k^2>-#>EyW9I}mhHQKD*0uoU=Ie4mV2w8Z4R*o z`)5>yx3IIqcbm*?@|H0D7Q(z##(b1OsVNoEA`a83Q+hr)%3d1 z$21a?F!keh6Yq{1^Anoo+@kYJ1F<{kD0rJXsj07qgrICf=}{fLmu6Z*N`k`Q9nRR%RiLmEbfbV%`6cZ94CgP z*`YL%>P@~Kr#^*u&e$Q*onXh^%_U|-8P3~dJjF%K+JF*%OG6`)j5_3Jy)(rOaWi?m z|BH9_x3K`X7$Rj+c5l0(;ovRxOk~=jZZCr+Lk6goR?s}NMp$Tb^B(>puB?$VIW-mj z{NI>>OH%7}B6<{CJYFwrCB~y=b@z+{zf$hNiONS5uTb{DkZPzBG%q4; z$lut|lG0_v`eE?<=@U6w#tSh4SU4#M8S#33rPYX zx7%<~#*zOY0Spw2mgiulI5U#hVli*elSI=dn^TK4tcrBXZT$R$8S~h|W$+0mAzDB` z>|kX(Z0IFl+s?H2*3dC;+0LzU@_lNNXp!igMx8xZrtIffl4x-iS7-enaB)WB^r8;( zdn2JCqH{BMdHU!696s1ys~6RX)zauood z>ky(%`LPjSTP5-;X{Eg-4^}HRvfl}>JVC~Xau$d{59EZ)`zU#(y~U?;Te2rKmWziw z>rJQ^aW9+vtjA@BgGFRiR7w@;;PX3}{dH5GoKW|H9{u^*Mv({1ZFkNp*fBa-XdDwh z$o4L_rL@;YaCf*$l-7TWpAJ4k#O!0VwRpTs5}aWZ#os9hQn!$vw+XI}I5JCNifNBL zJ3bTQ9w_BG1LIaQ2&)W+NlLS8k%H7s)hdNEaM}d%hgD>aVvpg`m%YQPEsHMuxqEUq z>0r(f48dgN+;!12cD-?U?nNg9UkgBumG?MHs+rW5>yT)Kg)MZ^xL)GH@rx8X9cKaW zYO1*r^)7D0|0yg!)S!C=inW+~Eh}f!$L3j@?48LRyUG~qbw@y3#;PB~jxteMm1)p} z+jGf!=-KmtL}dp-3_)k^WE!NE37>)a63*%GX|1QLsK&_8#?0LqwS&ckI(K!A+{G@A zeMaMb<;vv~Z))P4ql> z{fV1&H0BV4%hzwpmgQJhU4u}4DK_>VAWg(m`B?s9_>9M}0qPv1(+d7}DAsZmpQ)QtYu zD6@NkzlBH)jN(xet?9Uve>Shu4th{d#~<^1a9`-*6EZePsXI-P(Qp0H^DM1II7_V@ zYr=|Z+gUhyT%yI@&WR8Q-RUjvEye#ptTUoS5OCyRE~mmsBM&aB&|2B+3rLn1|x)vHMxuNR)W_h92*>x+5CGkjq+ z5tL=JVEpc49Cp%hhNQ-5pD|cs@3?!4aZd9N`}q|;!gVu|k<`C)6)s>y_VOQv#Krb< zWP9Ett%vcFHWt?X!_dJF8%W!ffh#!?ch8*mnm0;ByBLH$j-GpBdVmcWEjvBOLdn_TU`4cF~Pw!6-DsUUd-*EKoKb zWrg94A=07O)mn(nJ7?!iydbZBspEopAPz%cL|)WZy`AVVWW1e%U<29x?K3yAt%Nar zCG6;QT~I9kXUnqKo(V3YE$IzyJVVb|^y<4&E5&*f>jd+Qy|3e0xrOfX35ltXZcai) zU~CqOJ3ZU|^0#mu(fn_JhnBx#c`&~hQ_jWTX}Kc>H4lRZXo-e4Udj^d!YpwM;H%1# zj__zB%=~9pD#+2$)CPsrjG1y{nL4|M_w8Ck#ZEsS<(*ky{KHgMjOYB%jkFIoaAP$b zHrttg#JxYYiumfz$5k!$A{C*4p>RQ`eNcuz*x~VyK{K(MgJ;39VmgG;HYKt6#Oe%k zu$1$1h>}{6w8BLGuCtJ#%Uy29dja`BgZyh#B=4JRB|z+1)bGhLIwjL-j~7o~&?P0d z)Bo^mqza*D&LXz8&916ClossQG!bWuFO{Aa-ekFb$DFlQOXZ_l(hDxsBi|R7OOf?7 z)t5cC@c!Bh*NE?rv<;iP&wnu73LFY=k`O+(`na;LT(F0%ox~SDqPwzC6yQOuD*2!Z z`T59Bhr0bYzWNtqDAOfI3vN8Jlk34z9QeI|9xEOOA6j%;V}nI>EkOGUl?OzZZyztu zwg?mcn^mk>ac;sR@llB1^6Ot^;k#YXR48LdxD4Y>3k1YMKa1?d6bsPyPCAB3|^I4>C7DJ%8$}qFnd-i zioA;KYl?k&EA(B}k~mZ{KheIvfEUX?cTWg{$=C~AY?+1X2up~$US$&EM>kxgOnr4T zm>n-6HFfSG7F-d*W_uK?yvC_&JsAEjuKE|p>A=*PnPSrIsjFj2hEv;n7IGxmkU)d(w%hn#XQ=K0<+gF>Kzn%bc{aLOb>R~GpV*4 zI{&)$(cJgzSmSzyIv#hMmAS|0GXvRIAQS;zwX7ONg}mQ#2?Kl_-^|Fjt(|YjuYc~2 zDzO({kn&+&?o;Sdo!rfBKd;;}{c1E3ld$&J^%W&SteT?S2;&`J56!Kb-F@!u3v#wx z`jo=R71Wq_c1WQ!U$ArytxYUtj-v*ab&+)=sC&*_t_(wXz;bK%gBo{Bs=kV;A{+L} z?G%KYJu=i9&(7eDEGfvIxnxljBUiB>YjC2g-E_Gel6M}fvu5VX<9Ty^Y9pRw=&aM5 zoN)%>T|*Y$bMuFLT2@;h(l+%XV`)RcIiiAT2$sBH9)cJg3mm6Py-+v2)ZURWT7655 z;phqF&+_If$h}{*r|Fzgx%XtFC`VpoRI+9a&xR3np;snX3Rq5$+K@YyPfFs^hI$&8$0!8*kaO*My^DR3sVq{XozbVDVYsQng$Pau=?~m^2a2xZdCT zcq0=iD>aIYCv!f!b_?02b`RA)lXXvJe}#6uVSeK~k&<7bjulWemh)QAU*OhN4j$Ci z&@)uSdgW~Y;+-xlqVcaay!S}pRjikw@EEFP8}Y}NE>(Q~&c(PiY?+l^s;=Ya+pVjn zFrUA6GZ>6iu{oMWxt3wrm*dQ6GE+U!b*K59xpy|fUn_O=$|~%eOx>t&L7jh!v*#I6 z#G`P2mfOOa6gl(Uj|v^PM^ME?&R^GO=5)jNyA&xtp;=FaN-kQk5jozQ*5rrS^JYCZ zx0A~vaC^u&zcr#q_50OsKh(s1x!Y$b!^^j)S1jx2(|z%kNs&B|BMf?PxID_NI%HcNRdjZFut zspHA@bH(b?_(F?buks)9!c$79(i651*W1hk5rAu8865yKn6~|w_=a0S$-vd z-=o|~srjXeX*|pS9J;u#xpLKm2(j91C11Kz`ry{DmNpH+e=BLli=@w>*(0i4*8)IK5_}$ zVYEAhesZzz3rmmwx$sF0UA00~fU&5^D&$+e`Lq9e`h_g(GwFmZ4}!f4s=lYtZj7m= zekIT?czNpf{?KwlPi|;%6pL^CU3tp{mQq#bu&@`DQ1i6=$FG#M?!tK@v+nmwpB?B{3w z^grs(N}ui)+&4JpT(x!lLxzU;w(MMDo@)?SctEVO%I_MLn`tdE7Wd*q`M*btcD5R$ zd!zEIXM2oxQ^Db>q1?L*s@?%JI`dHuV1@oOF4JXewb7*<0O;5;jFjdOWvc|eI&;V$Ckx%gJ*wSv}dg<|Dpgo`S z#F8NlVG+ZIhbsznu%ZJDV~MSoDcxT#{Z-vI?lA*%BMj>sYRq4xj2l&?tlb^C{CnG! zdj@ugu&FP+`;Nw@$L zan&Ik!=1bE>Wig&QN`6=qoTFp>y1mDNwiHm&W3t%`<`L#@Xy{;DN{nwAj*v$Zg%=4 zf`BscGMX!IMK1*JStiqT6@iAnH2@p+qfbY8Cvc7pBTGTfh+6MHK1VN?bJ)RfKNl4x z7Y#JHgmMhFVyK=t^$(&O+60UlJ2vGzEU%?TNGfc9zX4aHFGl%-jU~%(zDIrA%Nlh` z-5QZt8y-lI0!~VWGEnx-V5ZN6{>^1O z%EU+=@8**Tze$xG)ZEk9Yl~Nb0~%Z$`KRjM&AtAt_eCF@DvqbKCVSI=v@RwJXSLdJ z(ZSEN^b98=RKh7vUw{*#$`sZ97?Y4Dpl_(U*8ouq-f_1V*D11!FwrcE45i#P#3=3V ztuMX$c(a6qpSrToYMt3CB%WX*w3;z8DVC+wKv35UA3_p@t35v|68S%a|KmFU0-XM+ z$wQ~8a6&%Yb5DZtjDNcf(hZxZ;#*YyKCU#21h?4ZzMc8Id+XQV%S)>K(n$aJWhX%9 znv%|mkXZ77UnLcKJ?ww~%JkkRMgO-yvt_N( zKDE;C$;!Tor>GviKy-K$jG>=jH&b6j9f$<|A&k_q!x+AuzwXyUt)7cCB*fXeteR_5@lOKfW`)1j31ZO?)!*@A@O+i-(OvIkjt!>WAo0kvbi zafZ+cTgl5C{=Mn#Em|Z<=~>=I1eA7+&R@ZLFAY{!coRII{X>9(MubTUPVcBeSJy&~ zr>1tacuueJhOaJe;wPTwt!kL;hntS)6x(H@`}gL5*{*8agz9XC{nZ$~&D5J(9ePjr zFIKKlq7?IoEo6c-wJ@(YBm_#XRP$JC2>QZLM;Z&Mo(R0#q@pp(Pn&6$^oQYtFM98U zu#{G2Y?HbJKdf4qNr9r1D(+#{o%@cvcMIK9$XN*0@TtlpI(BLCvw; z%ovrS%W-9G-x$2OwQ0h(zGPHx@Lv{Pd8u>smx-5g+n4W!r>1R-t?Z-Uuq-G2(rJ5^ znqlj9oCQYj3Hf~q;q2N}*yIS`AnCT{=R}g>bVOg;x6gg+m3NGn+w61W!WajVzAB^N z_{AB0F6^I$d;C2QW^YXwP>nBg9J5<^M2j27OLCL11RGzYQM%B}BvgBVpOnd?o~WsAyK`og(2=89QnB{}F^dAfNxW~% zu8<=>6&9A05}YXI6D6QGuCXrjm*nXN50Jq`?I*6X-@T13@FqqkeSh^S7ZM(RK%fqr4PO7 z8@8Kq79(59W8*iVOP9Py#r8Lz?hL6`>8#t9Y=s3D%{4ueba2D$V;z>R^*G=8kH{2< zQU+90@CyYx_1;64A&GN!!OzPgHJBT;mG^Lm6h{3<-x0fq)kQ`1Gs5#bCBum!y*t0q zP3vgHFHQKhe~a$XtL0B0?4Tb*XnF?=kn8xa8PYSvOQL$s$07G;yq7kou;(^~?Yt$F z)0I7nZJR=h+)KGPjKT+voUjpt=d(<>j_7)E{CiBn546Uf($8;jbFV&^g&O+&jvgD_ zCp5L1uQ=!~%96+SnR=ETG$V4^uy8z9cwtQu-_^Z*`NA|o?Qi91G75wxIr6h6fD^e$ zI^st2$X2>K$ziWd;Ijc3nW4(6c2IB2EevTvV$oae-*KY?)V)l#>PzKRVYBn5M{ z6y_h4u$m)({mhjuu>V3**eEKYa9$BvW!|N_3Uy?PTJ0I#z984f50#zP5n?5lP&lrz z%^f0Ds}SfTha-%T-+zpDe@sBwy4R!020_36eMDsHW=52;>>{fj(SU?r<&%4`A==T@ z<4d8J7{2?^g^1E{{CJL{1rj!pqQ&l1+W(9a?Rfo>+cB^2g<@drR`G%Yqh zeBt7GU@Gb3$6}Unr2NAiZ^Ht@fe+4Wf2XCM>2$ylJ@7#?rU}&6hl1uwvzF`S>O_cc zSj1XBPAv4JG~H8s6%F7QAcWo;_S|R#Qs)pLy*O{ z~E_vtTZK?OQO=>hvuYt;azrqzw>7{lUhE>d=z7cDE0wtP#ovo|r27mam=&Kn3U{jx(k)ST^jbJ%Tom;1CPg*2v{RD3NS_>~@z99Q-CXLohjVns%)<{{j4tOeRG*|pcAZ__ z2SjEx^dMQh1#8qW*wANB5Nue?120;gT&ihO>IfcMiu19l@4kyQhLyLTL|;SK2+gXL zF-=DC(hlWIZ3*yUaE%Fd3ur5c9;WI^>M&(sY!mz4sD)v*z{r#vZp zy=LkHQad<9&Nx>?l)(tYNy}mhH7coSL#ZJ*?lnV*smSK<0B6@tz4+lRX$B&qvRgjP zPFBRc9+8QJ9z2|45&EnMv5>@w%3k`5dJ{!e&St+_4bmt7PEhKTc`&?D_1JtS9bX_@b-+NE+p}B3hN~^O;R?9s56pB-eA$v ztShWXN;l0L5Sd<3(&ay<*Xy(TyDSLlEF*Y%m+uPRUa@HKAG+s(I&paNE#g)~9h%B# zUEwfNI%VE?NG5tcub(1rd$!r*(T3+bF#FkdCiEt5>i=z~U3-iD&HDpHejUE2Y6 z2183IYYE2Hb;jvlr7I4Vh3OGR?k`{2nA%u)Ws-H8Bq6%}6SLP=JD$}Wz)=@Q9<}0K zvz;=@=OtkU6=JSf*Pd!^!C`O+TY#QnoR(ki;?t>u&@D{-8 zOQq(*-_vpMTf6A2S(f712gK<32LtN0InK+r-sXf>aG8abD%QFJCVq4Y#rJz)TwHPK zGi@opYoxT4OeBtd#=D9GW~2jJ&CBZbadib+5`WTarANvJVvxkC1SHg1+w)m)uLs68 z2mo~1*LTZcDL3*4sE^GF*)|nsD3mMv5(*qr(1|22zrm-;bN&k9T~F+Jkz4N;H>`al zK*81)_b%GCAvj*C3}$)EQcn#%mh-XV`qKrx>CE5Tc|E_`D^zUVZ7o#;Q|Xa^mgY4K zC?E|bOGt%S*(!wf$+mKsTN;^!HC&7h_#pdrp&ZJSefMWs@uHft-;GavMqTQ|dd;H> zNVR`RG@K`^&%T1ycl%;-q%1>Bt}fcnTlLYa_M6D}&pK_BHL0Q2y;Z*d=+gan@g%el z(p*Ij8#uKX(UR=;e(Y9(OzK&zRr#UyC*hGWafZd zPgoEsFz3$;qZaF8iwz&)nlXZO)yI@fxgpte{8rLIXV%Rk$^ zGJ8ZQ@Lr9mHe$S~Sla-cr{S+fsvT3)S?$nWxy3c`q}a4SkbI4(ywH}{qpjB7r`W`r zfd^RwZWw(9J_&47C2UW&DKzm-@Yn`+{H}rvB_q@)`q5xs@HpX7C;M0}w#iIioJ9&G z^WVVzHc=CQvx6{cw7u!OgZPV)u{lWY3wZI~zGB=;7IHrzA5+NBN48F{m-LnFInk_! zb8i?a41Vt6T|0a2D$mh--w^d>6|qzirtc&NTJ?os z%w(oSpfqm5cmdZzzLf5Lars*7Y$>kwicoRj^x(CHdKdLC_caDTECmi1S@|)%PG{aYZqZl43Y7Esdu^j;)CX$?#QChryLaa_tE}K({9~N4 z!s|&0!=2m_tBOnkwF|oIs+DTW#Y)L`2tDVwc_MFuS&x5gyYb@8tgA{iioHZya?SejhVO8Q)-i(6W zSJIcjIK-#M=8nJ4{w`K>M~8ZW?#i;SHnX~XS=K$KEnRn|E#hl?B7@{azV-0R{T?s% zY~gg)sU=i-VN%JMJyxxR;qUjX5`TR2IGfE=|;(RN^uGCE_)WBhLj*y(UMw{2sLs8?2TWtwnO) z9+l}63Y%W|o&h0c^>-o zuD^#usUb9$%x)<*qK8yBt(WZW?rccy;^^VBa8GcZ9UYFiHf}BnTY)L{?|i#;IhJfU zfQooczUualiznOPP^z$YPOrIu8%T^mJ(-1?%{OS2WAFL(o3QjuS=Ab8V_m|6~F0Urg%63lq8p0CU zueIlpznYoOe)4jS>x^FaVU3KZ`y2NdqW*SF7kqNjR`S^|%7QCRWHo?!L#X$IL*~S< z{G%<&7Sc%qODI=a%G?raC5N25yt+vtWXFFG!m2!|mo{c9*&SvnqJ4Kvd+pfI@vWNE zDhETvbHiYw1%V4FrTSFxE;T{pNpnfg6!YGh!9pyM?xlOxA~G$Y)9H`Xzqq~paTWlMo>a1~@0P-{%|U4_r~MH?cp&o-B!gY6 zQ6W06*Qi`I3w}`lm`@Ohn{@K_vnJOM%x6uWWw5$COga$rXtKLSM89kGw1#U)Jdz01 z>HRj7S=C4?+PQTS6O3;f)3DS`2yW zO~Ymd;u3TiTnf%42BQ2x6EfKArp+&GjLc+v=+)auBK7_pU2P_(w9bI)xKg5|=$=BK zB>QFsTcgtNRl+R2tJ95@+|*~bXTT`}el~GrKW$HI)X4Eely53c!hxq-9&4PLm(zFH zP$Y>Oj;qI6+wE%J|ut zKguLWWEeY5%*#^z)Yx0nU4!g?9Ad4QkBXPA=8UK0vDcw>x9vKwZ^GXriRY8*L(xL>0pkVXPE*+MZ!mQ#)O&mB znng|0tUOlPy8bm=5K*%6hiFas?>arOhB9n3kj{jz-~@rSjgY%7Z%}dM%=m-O>LeubfeAGdi-eNKEHk=vI|HvKdo}e| zn|;N*N?$?3N$OZ*pMlS(m@+I}{D}4D$p`hX*)jGnT=K{*8TBm&O{)D6d-u_6j+64| zQbQU(ghAi$scAODD4f>zEn$=LBB?uVBAgiQoe=&12IRBl#O${=X;@69Pp?c{pbX|J zeJfz?FMRolA55q}*w#t@9>nhtIzv$g5+#2BVA65brd03l*e-q8lis`buFN?k@u>;@ zXsVL_-bU^g<{bvi<$0;HdigMwNTfEI*TzFhzfUf4#8;p5sZVA~_f367T;SE;H)X$u z*^kSEUGhDccopC6&fJR!7A=1!&!HL$Hy=6{_ljkKisQGNv8jK}DSlw`4h=nMGtNNJ zc?&J&^Jd3~!P9rYX?Q#?B2zbdgSKmOLG#ic5H;gKbecfVg z!1kk*xaIrcAfF~~ZBKBWtoAw+9DdetA@9uhvd}GS$r5myO^>%P;#ru36=8|hXDUnd zedrUZ3RJRoaqU}Rp@u0syXwzQyFrb%nx6`xb1&gAOd-Sdng%Rg&l}0S@+qt0RFgb0 z+BK98Y)XNAkDIM+9TFa^kS})ow=<#M5UxDryD=Lq_J?pg*YDW0+4plnO9c3ZI431y zUv?#|?!+ZlbqT_t68)RuJgpFDWngzktjDKw^Kq7nvw;Vt18rXxy^fN4KwQxhTQy4*+;EJ-Q^WE%`;&HF&1idJpQ?0kDJjc1 zY#MY)qilMjYlA~~hDxTJ9uW8iG^ej;}m9+;#svdiOi|VabkxS^~b(uT$b14Yo6l2ZJGAPi<7KRy^ERB zDuNZTx&$maS#Hco)mzuM_jPPg?nv$hp*W04Tf|d0xLB#GWkdD3|Qm-;*fF z(|1>UE$5fF-12f6#f6)-p)GyIs|exio*{kiK~2DMbSXoBr8GNyq}x{KWqfNP!QB)t zyWe^v-cLC>c-7_f4HH%>jh-cT;L){Jc|fPB`P~@bT7v85%mT#= zFgcH^7*deC#O+pqLe&qMZ^E%dY*(Sm!d%D#Ng1@5!1*i0KFaj4kx%!JY-`V&375%g ze^yCW11ITt>+x-AA?_#!-no5sB7h$=J4s#dMNefpVKoz`qT=g(KK9**cjEi z#5XsC-*Ya!D9J{YQ=>vA;rzO{=)qcIT&ZYxypr{PU!&nL7#62=qkyO>l0<^Kn9nDR)B7M6demdlnR8T4Pef3UtW3+N8v8TgwCFEV?II*gKpp;BhU& z`UG~ox~5dSvRFcxc)-pz5co(gZY~S^&Wl@U;QXr?!9Go}zFGm{qk>AZja~Ji_gCd? zM2p^EJ9Lr2?^b@}Ndn6d!EzYt4*VYJ!cq$+_TBZj!*yBdZw`X}pIMI=Y5ZETQ0<8hLJoJ$I@TYp`3qg^mQ$Xd8g{kCnE0QMX`kH}{n_EJDndN#RwTi69w=M(DA2oW--drm;u;1!A1gR< zY80pg+RMMKWRExly+Qj+Vf)KRo=DK}t~rt9Nb#T%@H8+>^;-ktf@DCzOUF;>&?Qur zlOOg*Sap(sxfTJaMpfAwjcfstwucDih>JYSM}q+7ZASa4;O|_%Ww7>F=-q*!4`MD3 z^{@GWZ2KoP$|eapDR24Ml2Aq>18nZMAF?rJ${U+$!cA^v0{Oa4Vz%)c#E=X7jH?Ww zny)Q~DY=FH0<3ghk^*k^oYuz%2%DNIS?CB+X>ic6?9rzpCbyU1<~ z`O}$UK!Ml0vqW{H6WC|qGW+`17nsDrOgm{^F6oC1ylZ!-1EKFigr8(2`ICqxP`6%B z%)VF53wPbU2ylzB01Ay*AhXy2&2T?te+cjq^JezilqXti=;(4SKWxLhPISTuMw>E< z1t*lMoF?VnW~3>ao7Qk$t}>^8uvP?O*;2~sB!TZ*ti#z>gw}5UaCX2RZR+8(1tC_5 z*9JgqkKtXHJIx5T<=(nnkHuUkiwItMZbA}6ERQ$kiJ~VKG4>0K5jTXcOjog}4+GX#1 zz;g6-15%S}7_*IicBX(#NS{U_yfixbwJnD&oOfoYFXN1<#>dceFc zO?N$}2=}+ei6|E!fHzZsC^-9{3HL)XDM^6*?CE4R#bU|oObJ#EOrhN4I5A*?F{gL;w=6lQluBAAC?PBUWqPC}yul7NyeCN#g}HVhmd$k6}~;UBGSZp|KC z1t%1L?RN~dop#<|nbz3%@nKAZwShGT1^g@gAfF#l#9jzzlOC-fMuOvV|GASTrkMXO zG{K6_h{%@6oC7t+{nssgap{rb7m~gZ=X6L7xlg!(G(6D82XZ9Z3MGf3=bvK|5UE3; zNce)wvgv#hBzV=rP6`x|Za*9hNhF(YJnk literal 0 HcmV?d00001 diff --git a/dev_docs/key_concepts/saved_objects.mdx b/dev_docs/key_concepts/saved_objects.mdx new file mode 100644 index 00000000000000..d89342765c8f19 --- /dev/null +++ b/dev_docs/key_concepts/saved_objects.mdx @@ -0,0 +1,74 @@ +--- +id: kibDevDocsSavedObjectsIntro +slug: /kibana-dev-docs/saved-objects-intro +title: Saved Objects +summary: Saved Objects are a key concept to understand when building a Kibana plugin. +date: 2021-02-02 +tags: ['kibana','dev', 'contributor', 'api docs'] +--- + +"Saved Objects" are developer defined, persisted entities, stored in the Kibana system index (which is also sometimes referred to as the `.kibana` index). +The Saved Objects service allows Kibana plugins to use Elasticsearch like a primary database. Think of it as an Object Document Mapper for Elasticsearch. + Some examples of Saved Object types are dashboards, lens, canvas workpads, index patterns, cases, ml jobs, and advanced settings. Some Saved Object types are + exposed to the user in the [Saved Object management UI](https://www.elastic.co/guide/en/kibana/current/managing-saved-objects.html), but not all. + +Developers create and manage their Saved Objects using the SavedObjectClient, while other data in Elasticsearch should be accessed via the data plugin's search +services. + +![image](../assets/saved_object_vs_data_indices.png) + + + + +## References + +In order to support import and export, and space-sharing capabilities, Saved Objects need to explicitly list any references they contain to other Saved Objects. +The parent should have a reference to it's children, not the other way around. That way when a "parent" is exported (or shared to a space), + all the "children" will be automatically included. However, when a "child" is exported, it will not include all "parents". + + + +## Migrations and Backward compatibility + +As your plugin evolves, you may need to change your Saved Object type in a breaking way (for example, changing the type of an attribtue, or removing +an attribute). If that happens, you should write a migration to upgrade the Saved Objects that existed prior to the change. + +. + +## Security + +Saved Objects can be secured using Kibana's Privileges model, unlike data that comes from data indices, which is secured using Elasticsearch's Privileges model. + +### Space awareness + +Saved Objects are "space aware". They exist in the space they were created in, and any spaces they have been shared with. + +### Feature controls and RBAC + +Feature controls provide another level of isolation and shareability for Saved Objects. Admins can give users and roles read, write or none permissions for each Saved Object type. + +### Object level security (OLS) + +OLS is an oft-requested feature that is not implemented yet. When it is, it will provide users with even more sharing and privacy flexibility. Individual +objects can be private to the user, shared with a selection of others, or made public. Much like how sharing Google Docs works. + +## Scalability + +By default all saved object types go into a single index. If you expect your saved object type to have a lot of unique fields, or if you expect there +to be many of them, you can have your objects go in a separate index by using the `indexPattern` field. Reporting and task manager are two +examples of features that use this capability. + +## Searchability + +Because saved objects are stored in system indices, they cannot be searched like other data can. If you see the phrase “[X] as data” it is +referring to this searching limitation. Users will not be able to create custom dashboards using saved object data, like they would for data stored +in Elasticsearch data indices. + +## Saved Objects by value + +Sometimes Saved Objects end up persisted inside another Saved Object. We call these Saved Objects “by value”, as opposed to "by + reference". If an end user creates a visualization and adds it to a dashboard without saving it to the visualization + library, the data ends up nested inside the dashboard Saved Object. This helps keep the visualization library smaller. It also avoids + issues with edits propagating - since an entity can only exist in a single place. + Note that from the end user stand point, we don’t use these terms “by reference” and “by value”. + diff --git a/dev_docs/tutorials/saved_objects.mdx b/dev_docs/tutorials/saved_objects.mdx new file mode 100644 index 00000000000000..bd7d231218af1f --- /dev/null +++ b/dev_docs/tutorials/saved_objects.mdx @@ -0,0 +1,250 @@ +--- +id: kibDevTutorialSavedObject +slug: /kibana-dev-docs/tutorial/saved-objects +title: Register a new saved object type +summary: Learn how to register a new saved object type. +date: 2021-02-05 +tags: ['kibana','onboarding', 'dev', 'architecture', 'tutorials'] +--- + +Saved Object type definitions should be defined in their own `my_plugin/server/saved_objects` directory. + +The folder should contain a file per type, named after the snake_case name of the type, and an index.ts file exporting all the types. + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { + '1.0.0': migratedashboardVisualizationToV1, + '2.0.0': migratedashboardVisualizationToV2, + }, +}; +``` + +[1] Since the name of a Saved Object type forms part of the url path for the public Saved Objects HTTP API, +these should follow our API URL path convention and always be written as snake case. + +**src/plugins/my_plugin/server/saved_objects/index.ts** + +```ts +export { dashboardVisualization } from './dashboard_visualization'; +export { dashboard } from './dashboard'; +``` + +**src/plugins/my_plugin/server/plugin.ts** + +```ts +import { dashboard, dashboardVisualization } from './saved_objects'; + +export class MyPlugin implements Plugin { + setup({ savedObjects }) { + savedObjects.registerType(dashboard); + savedObjects.registerType(dashboardVisualization); + } +} +``` + +## Mappings + +Each Saved Object type can define its own Elasticsearch field mappings. Because multiple Saved Object +types can share the same index, mappings defined by a type will be nested under a top-level field that matches the type name. + +For example, the mappings defined by the dashboard_visualization Saved Object type: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', + ... + mappings: { + properties: { + dynamic: false, + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { ... }, +}; +``` + +Will result in the following mappings being applied to the .kibana index: + +```ts +{ + "mappings": { + "dynamic": "strict", + "properties": { + ... + "dashboard_vizualization": { + "dynamic": false, + "properties": { + "description": { + "type": "text", + }, + "hits": { + "type": "integer", + }, + }, + } + } + } +} +``` +Do not use field mappings like you would use data types for the columns of a SQL database. Instead, field mappings are analogous to a +SQL index. Only specify field mappings for the fields you wish to search on or query. By specifying `dynamic: false` + in any level of your mappings, Elasticsearch will accept and store any other fields even if they are not specified in your mappings. + +Since Elasticsearch has a default limit of 1000 fields per index, plugins should carefully consider the +fields they add to the mappings. Similarly, Saved Object types should never use `dynamic: true` as this can cause an arbitrary + amount of fields to be added to the .kibana index. + + ## References + +Declare by adding an id, type and name to the + `references` array. + +```ts +router.get( + { path: '/some-path', validate: false }, + async (context, req, res) => { + const object = await context.core.savedObjects.client.create( + 'dashboard', + { + title: 'my dashboard', + panels: [ + { visualization: 'vis1' }, [1] + ], + indexPattern: 'indexPattern1' + }, + { references: [ + { id: '...', type: 'visualization', name: 'vis1' }, + { id: '...', type: 'index_pattern', name: 'indexPattern1' }, + ] + } + ) + ... + } +); +``` +[1] Note how `dashboard.panels[0].visualization` stores the name property of the reference (not the id directly) to be able to uniquely +identify this reference. This guarantees that the id the reference points to always remains up to date. If a + visualization id was directly stored in `dashboard.panels[0].visualization` there is a risk that this id gets updated without + updating the reference in the references array. + +## Writing migrations + +Saved Objects support schema changes between Kibana versions, which we call migrations. Migrations are + applied when a Kibana installation is upgraded from one version to the next, when exports are imported via + the Saved Objects Management UI, or when a new object is created via the HTTP API. + +Each Saved Object type may define migrations for its schema. Migrations are specified by the Kibana version number, receive an input document, + and must return the fully migrated document to be persisted to Elasticsearch. + +Let’s say we want to define two migrations: - In version 1.1.0, we want to drop the subtitle field and append it to the title - In version + 1.4.0, we want to add a new id field to every panel with a newly generated UUID. + +First, the current mappings should always reflect the latest or "target" schema. Next, we should define a migration function for each step in the schema evolution: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType, SavedObjectMigrationFn } from 'src/core/server'; +import uuid from 'uuid'; + +interface DashboardVisualizationPre110 { + title: string; + subtitle: string; + panels: Array<{}>; +} +interface DashboardVisualization110 { + title: string; + panels: Array<{}>; +} + +interface DashboardVisualization140 { + title: string; + panels: Array<{ id: string }>; +} + +const migrateDashboardVisualization110: SavedObjectMigrationFn< + DashboardVisualizationPre110, [1] + DashboardVisualization110 +> = (doc) => { + const { subtitle, ...attributesWithoutSubtitle } = doc.attributes; + return { + ...doc, [2] + attributes: { + ...attributesWithoutSubtitle, + title: `${doc.attributes.title} - ${doc.attributes.subtitle}`, + }, + }; +}; + +const migrateDashboardVisualization140: SavedObjectMigrationFn< + DashboardVisualization110, + DashboardVisualization140 +> = (doc) => { + const outPanels = doc.attributes.panels?.map((panel) => { + return { ...panel, id: uuid.v4() }; + }); + return { + ...doc, + attributes: { + ...doc.attributes, + panels: outPanels, + }, + }; +}; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + /** ... */ + migrations: { + // Takes a pre 1.1.0 doc, and converts it to 1.1.0 + '1.1.0': migrateDashboardVisualization110, + + // Takes a 1.1.0 doc, and converts it to 1.4.0 + '1.4.0': migrateDashboardVisualization140, [3] + }, +}; +``` +[1] It is useful to define an interface for each version of the schema. This allows TypeScript to ensure that you are properly handling the input and output + types correctly as the schema evolves. + +[2] Returning a shallow copy is necessary to avoid type errors when using different types for the input and output shape. + +[3] Migrations do not have to be defined for every version. The version number of a migration must always be the earliest Kibana version + in which this migration was released. So if you are creating a migration which will + be part of the v7.10.0 release, but will also be backported and released as v7.9.3, the migration version should be: 7.9.3. + + Migrations should be written defensively, an exception in a migration function will prevent a Kibana upgrade from succeeding and will cause downtime for our users. + Having said that, if a + document is encountered that is not in the expected shape, migrations are encouraged to throw an exception to abort the upgrade. In most scenarios, it is better to + fail an upgrade than to silently ignore a corrupt document which can cause unexpected behaviour at some future point in time. + +It is critical that you have extensive tests to ensure that migrations behave as expected with all possible input documents. Given how simple it is to test all the branch +conditions in a migration function and the high impact of a bug in this code, there’s really no reason not to aim for 100% test code coverage. From 1ba3d6776a2c33031c69704580bb9a3210e6bfc2 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 11 Feb 2021 10:29:24 -0600 Subject: [PATCH 04/26] [Workplace Search] Break out MVP from in-progress app (#91034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create a copy of the existing overview as mvp No files were changed here; only a copy * Update index to point to MVP copy * Wrap server calls in try/catch Jest was complaining about this and it’s a good practice to have anyway * Remove MVP temp EuiPage wrapper * Add route and link in navigation * Remove Launch Workplace Search button This not needed in a post-MVP world. We have had discussions about giving the users the ability to relaunch the legacy app in the beta (pre-8.0) world, but that will be in a callout or some other element. * Refactor onboarding card to use internal routing I simplified this by not trying to recreate shared props and typecast them, but just create 2 variable components that fall back to an unclickable button that is disabled in the UI * Refactor onboarding steps to use internal routing * Refactor statistic card to use internal routing * Refactor recent activity to use internal routing --- .../components/layout/nav.test.tsx | 4 +- .../components/layout/nav.tsx | 3 +- .../workplace_search/index.test.tsx | 4 +- .../applications/workplace_search/index.tsx | 9 +- .../applications/workplace_search/routes.ts | 1 + .../views/overview/onboarding_card.test.tsx | 12 +- .../views/overview/onboarding_card.tsx | 46 ++--- .../views/overview/onboarding_steps.tsx | 21 +- .../views/overview/overview.tsx | 27 +-- .../views/overview/overview_logic.ts | 9 +- .../views/overview/recent_activity.test.tsx | 6 +- .../views/overview/recent_activity.tsx | 22 +-- .../views/overview/statistic_card.test.tsx | 4 +- .../views/overview/statistic_card.tsx | 49 ++--- .../views/overview_mvp/__mocks__/index.ts | 8 + .../__mocks__/overview_logic.mock.ts | 37 ++++ .../views/overview_mvp/index.ts | 8 + .../overview_mvp/onboarding_card.test.tsx | 55 ++++++ .../views/overview_mvp/onboarding_card.tsx | 92 +++++++++ .../overview_mvp/onboarding_steps.test.tsx | 136 +++++++++++++ .../views/overview_mvp/onboarding_steps.tsx | 182 ++++++++++++++++++ .../overview_mvp/organization_stats.test.tsx | 35 ++++ .../views/overview_mvp/organization_stats.tsx | 79 ++++++++ .../views/overview_mvp/overview.test.tsx | 66 +++++++ .../views/overview_mvp/overview.tsx | 93 +++++++++ .../views/overview_mvp/overview_logic.test.ts | 72 +++++++ .../views/overview_mvp/overview_logic.ts | 114 +++++++++++ .../views/overview_mvp/recent_activity.scss | 38 ++++ .../overview_mvp/recent_activity.test.tsx | 80 ++++++++ .../views/overview_mvp/recent_activity.tsx | 126 ++++++++++++ .../overview_mvp/statistic_card.test.tsx | 34 ++++ .../views/overview_mvp/statistic_card.tsx | 45 +++++ 32 files changed, 1405 insertions(+), 112 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 8f37f608f4e282..bac27bddf075a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { SideNav, SideNavLink } from '../../../shared/layout'; +import { ALPHA_PATH } from '../../routes'; + import { WorkplaceSearchNav } from './'; describe('WorkplaceSearchNav', () => { @@ -20,7 +22,7 @@ describe('WorkplaceSearchNav', () => { const wrapper = shallow(); expect(wrapper.find(SideNav)).toHaveLength(1); - expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/'); + expect(wrapper.find(SideNavLink).first().prop('to')).toEqual(ALPHA_PATH); expect(wrapper.find(SideNavLink)).toHaveLength(6); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index c184247b253d6f..16722c1554ddf0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -14,6 +14,7 @@ import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; import { NAV } from '../../constants'; import { + ALPHA_PATH, SOURCES_PATH, SECURITY_PATH, ROLE_MAPPINGS_PATH, @@ -33,7 +34,7 @@ export const WorkplaceSearchNav: React.FC = ({ settingsSubNav, }) => ( - + {NAV.OVERVIEW} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 5678ad545d50d0..ceb1a824461325 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -17,7 +17,7 @@ import { Layout } from '../shared/layout'; import { WorkplaceSearchHeaderActions } from './components/layout'; import { ErrorState } from './views/error_state'; -import { Overview } from './views/overview'; +import { Overview as OverviewMVP } from './views/overview_mvp'; import { SetupGuide } from './views/setup_guide'; import { WorkplaceSearch, WorkplaceSearchUnconfigured, WorkplaceSearchConfigured } from './'; @@ -60,7 +60,7 @@ describe('WorkplaceSearchConfigured', () => { const wrapper = shallow(); expect(wrapper.find(Layout).first().prop('readOnlyMode')).toBeFalsy(); - expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(OverviewMVP)).toHaveLength(1); expect(mockKibanaValues.renderHeaderActions).toHaveBeenCalledWith(WorkplaceSearchHeaderActions); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d690dee4dc98cf..c469e5ef5ce98a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -20,6 +20,7 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; import { + ALPHA_PATH, GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, @@ -33,6 +34,7 @@ import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { Overview } from './views/overview'; +import { Overview as OverviewMVP } from './views/overview_mvp'; import { Security } from './views/security'; import { SettingsRouter } from './views/settings'; import { SettingsSubNav } from './views/settings/components/settings_sub_nav'; @@ -78,7 +80,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - {errorConnecting ? : } + {errorConnecting ? : } {/* TODO: replace Layout with PrivateSourcesLayout (needs to be created) */} @@ -95,6 +97,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } restrictWidth readOnlyMode={readOnlyMode}> + + + } />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index aaaf8cbd7cfe59..462f89abd6143c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -57,6 +57,7 @@ export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source_prioritization`; +export const ALPHA_PATH = '/alpha'; export const SOURCES_PATH = '/sources'; export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx index 68dece976a09ce..2b9dc98b035671 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButtonEmpty } from '@elastic/eui'; + +import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { OnboardingCard } from './onboarding_card'; @@ -35,11 +37,11 @@ describe('OnboardingCard', () => { const wrapper = shallow(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); - expect(prompt.find(EuiButton)).toHaveLength(1); - expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + expect(prompt.find(EuiButtonTo)).toHaveLength(1); + expect(prompt.find(EuiButtonEmptyTo)).toHaveLength(0); const button = prompt.find('[data-test-subj="actionButton"]'); - expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + expect(button.prop('to')).toBe('/some_path'); button.simulate('click'); expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); @@ -49,7 +51,7 @@ describe('OnboardingCard', () => { const wrapper = shallow(); const prompt = wrapper.find(EuiEmptyPrompt).dive(); - expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonTo)).toHaveLength(0); expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 2f8d06b71fc270..2d9e5580c6f405 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -16,12 +16,9 @@ import { EuiPanel, EuiEmptyPrompt, IconType, - EuiButtonProps, - EuiButtonEmptyProps, - EuiLinkProps, } from '@elastic/eui'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; interface OnboardingCardProps { @@ -50,25 +47,22 @@ export const OnboardingCard: React.FC = ({ action: 'clicked', metric: 'onboarding_card_button', }); - const buttonActionProps = actionPath - ? { - onClick, - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - 'data-test-subj': testSubj, - } - : { - 'data-test-subj': testSubj, - }; - const emptyButtonProps = { - ...buttonActionProps, - } as EuiButtonEmptyProps & EuiLinkProps; - const fillButtonProps = { - ...buttonActionProps, - color: 'secondary', - fill: true, - } as EuiButtonProps & EuiLinkProps; + const completeButton = actionPath ? ( + + {actionTitle} + + ) : ( + {actionTitle} + ); + + const incompleteButton = actionPath ? ( + + {actionTitle} + + ) : ( + {actionTitle} + ); return ( @@ -78,13 +72,7 @@ export const OnboardingCard: React.FC = ({ iconColor={complete ? 'secondary' : 'subdued'} title={

{title}

} body={description} - actions={ - complete ? ( - {actionTitle} - ) : ( - {actionTitle} - ) - } + actions={complete ? completeButton : incompleteButton} />
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index fc3998fcdfeec3..9f07196b2e9fd8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -11,20 +11,17 @@ import { useValues, useActions } from 'kea'; import { EuiSpacer, - EuiButtonEmpty, EuiTitle, EuiPanel, EuiIcon, EuiFlexGrid, EuiFlexItem, EuiFlexGroup, - EuiButtonEmptyProps, - EuiLinkProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; @@ -144,14 +141,6 @@ export const OrgNameOnboarding: React.FC = () => { metric: 'org_name_change_button', }); - const buttonProps = { - onClick, - target: '_blank', - color: 'primary', - href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), - 'data-test-subj': 'orgNameChangeButton', - } as EuiButtonEmptyProps & EuiLinkProps; - return ( @@ -169,12 +158,16 @@ export const OrgNameOnboarding: React.FC = () => { - + - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 6bf84b585da808..0f8f4b6def46ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -5,20 +5,17 @@ * 2.0. */ -// TODO: Remove EuiPage & EuiPageBody before exposing full app - import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { Loading } from '../../../shared/loading'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { ProductButton } from '../../components/shared/product_button'; import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; @@ -72,22 +69,16 @@ export const Overview: React.FC = () => { const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; return ( - + <> - - } - /> - {!hideOnboarding && } - - - - - - + + {!hideOnboarding && } + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 75513cfba3a092..7d8bc955294831 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -7,6 +7,7 @@ import { kea, MakeLogicType } from 'kea'; +import { flashAPIErrors } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { FeedActivity } from './recent_activity'; @@ -102,8 +103,12 @@ export const OverviewLogic = kea> }, listeners: ({ actions }) => ({ initializeOverview: async () => { - const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); - actions.setServerData(response); + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); + actions.setServerData(response); + } catch (e) { + flashAPIErrors(e); + } }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx index 0b62207afc5206..9ab7b908ad3cd7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx @@ -13,9 +13,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLinkTo } from '../../../shared/react_router_helpers'; + import { setMockValues } from './__mocks__'; import { RecentActivity, RecentActivityItem } from './recent_activity'; @@ -60,7 +62,7 @@ describe('RecentActivity', () => { expect(wrapper.find('.activity--error')).toHaveLength(1); expect(wrapper.find('.activity--error__label')).toHaveLength(1); - expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + expect(wrapper.find(EuiLinkTo).prop('color')).toEqual('danger'); }); it('renders recent activity message for default org name', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 43d3f880feef45..62b96442b9ba0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -10,10 +10,10 @@ import React from 'react'; import { useValues, useActions } from 'kea'; import moment from 'moment'; -import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiPanel, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiLinkTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; import { ContentSection } from '../../components/shared/content_section'; @@ -95,19 +95,15 @@ export const RecentActivityItem: React.FC = ({ metric: 'recent_activity_source_details_link', }); - const linkProps = { - onClick, - target: '_blank', - href: getWorkplaceSearchUrl(getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)), - external: true, - color: status === 'error' ? 'danger' : 'primary', - 'data-test-subj': 'viewSourceDetailsLink', - } as EuiLinkProps; - return (
- + {id} {message} {status === 'error' && ( @@ -118,7 +114,7 @@ export const RecentActivityItem: React.FC = ({ /> )} - +
{moment.utc(timestamp).fromNow()}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx index ff1d69e4068305..c81d933ca38cab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx @@ -13,6 +13,8 @@ import { shallow } from 'enzyme'; import { EuiCard } from '@elastic/eui'; +import { EuiCardTo } from '../../../shared/react_router_helpers'; + import { StatisticCard } from './statistic_card'; const props = { @@ -29,6 +31,6 @@ describe('StatisticCard', () => { it('renders clickable card', () => { const wrapper = shallow(); - expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + expect(wrapper.find(EuiCardTo).prop('to')).toBe('/foo'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx index 346debb1c52519..136901f840b892 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; -import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { EuiCardTo } from '../../../shared/react_router_helpers'; interface StatisticCardProps { title: string; @@ -18,28 +18,31 @@ interface StatisticCardProps { } export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { - const linkProps = actionPath - ? { - href: getWorkplaceSearchUrl(actionPath), - target: '_blank', - rel: 'noopener', + const linkableCard = ( + + {count} + } - : {}; - // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) - - return ( - - - {count} - - } - /> - + /> + ); + const card = ( + + {count} + + } + /> ); + + return {actionPath ? linkableCard : card}; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts new file mode 100644 index 00000000000000..3a1bbfcae75ba4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { setMockValues, mockOverviewValues, mockActions } from './overview_logic.mock'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts new file mode 100644 index 00000000000000..787354974cb31c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/__mocks__/overview_logic.mock.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DEFAULT_INITIAL_APP_DATA } from '../../../../../../common/__mocks__'; +import { setMockValues as setMockKeaValues, setMockActions } from '../../../../__mocks__/kea.mock'; + +const { workplaceSearch: mockAppValues } = DEFAULT_INITIAL_APP_DATA; + +export const mockOverviewValues = { + accountsCount: 0, + activityFeed: [], + canCreateContentSources: false, + hasOrgSources: false, + hasUsers: false, + isOldAccount: false, + pendingInvitationsCount: 0, + personalSourcesCount: 0, + sourcesCount: 0, + dataLoading: true, +}; + +export const mockActions = { + initializeOverview: jest.fn(() => ({})), +}; + +const mockValues = { ...mockOverviewValues, ...mockAppValues, isFederatedAuth: true }; + +setMockActions({ ...mockActions }); +setMockKeaValues({ ...mockValues }); + +export const setMockValues = (values: object) => { + setMockKeaValues({ ...mockValues, ...values }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts new file mode 100644 index 00000000000000..69c843fe3821ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Overview } from './overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx new file mode 100644 index 00000000000000..68dece976a09ce --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 '../../../__mocks__/kea.mock'; +import '../../../__mocks__/enterprise_search_url.mock'; +import { mockTelemetryActions } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { OnboardingCard } from './onboarding_card'; + +const cardProps = { + title: 'My card', + icon: 'icon', + description: 'this is a card', + actionTitle: 'action', + testSubj: 'actionButton', +}; + +describe('OnboardingCard', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(1); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + + const button = prompt.find('[data-test-subj="actionButton"]'); + expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + + button.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('renders an empty button when onboarding is completed', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx new file mode 100644 index 00000000000000..2f8d06b71fc270 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_card.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + IconType, + EuiButtonProps, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; + +interface OnboardingCardProps { + title: React.ReactNode; + icon: React.ReactNode; + description: React.ReactNode; + actionTitle: React.ReactNode; + testSubj: string; + actionPath?: string; + complete?: boolean; +} + +export const OnboardingCard: React.FC = ({ + title, + icon, + description, + actionTitle, + testSubj, + actionPath, + complete, +}) => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'onboarding_card_button', + }); + const buttonActionProps = actionPath + ? { + onClick, + href: getWorkplaceSearchUrl(actionPath), + target: '_blank', + 'data-test-subj': testSubj, + } + : { + 'data-test-subj': testSubj, + }; + + const emptyButtonProps = { + ...buttonActionProps, + } as EuiButtonEmptyProps & EuiLinkProps; + const fillButtonProps = { + ...buttonActionProps, + color: 'secondary', + fill: true, + } as EuiButtonProps & EuiLinkProps; + + return ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx new file mode 100644 index 00000000000000..7a368e7d384ea3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { mockTelemetryActions } from '../../../__mocks__'; + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { setMockValues } from './__mocks__'; +import { OnboardingCard } from './onboarding_card'; +import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; + +const account = { + id: '1', + isAdmin: true, + canCreatePersonalSources: true, + groups: [], + isCurated: false, + canCreateInvitations: true, +}; + +describe('OnboardingSteps', () => { + describe('Shared Sources', () => { + it('renders 0 sources state', () => { + setMockValues({ canCreateContentSources: true }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(1); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('description')).toBe( + 'Add shared sources for your organization to start searching.' + ); + }); + + it('renders completed sources state', () => { + setMockValues({ sourcesCount: 2, hasOrgSources: true }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).prop('description')).toEqual( + 'You have added 2 shared sources. Happy searching.' + ); + }); + + it('disables link when the user cannot create sources', () => { + setMockValues({ canCreateContentSources: false }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + setMockValues({ + isFederatedAuth: false, + account, + accountsCount: 0, + hasUsers: false, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(2); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Invite your colleagues into this organization to search with you.' + ); + }); + + it('renders completed users state', () => { + setMockValues({ + isFederatedAuth: false, + account, + accountsCount: 1, + hasUsers: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + setMockValues({ + isFederatedAuth: false, + account: { + ...account, + canCreateInvitations: false, + }, + }); + const wrapper = shallow(); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'foo', + }, + }); + const wrapper = shallow(); + + const button = wrapper + .find(OrgNameOnboarding) + .dive() + .find('[data-test-subj="orgNameChangeButton"]'); + + button.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('hides card when name has been changed', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.tsx new file mode 100644 index 00000000000000..fc3998fcdfeec3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/onboarding_steps.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiSpacer, + EuiButtonEmpty, + EuiTitle, + EuiPanel, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; +import { ContentSection } from '../../components/shared/content_section'; +import { SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; + +import { OnboardingCard } from './onboarding_card'; +import { OverviewLogic } from './overview_logic'; + +const SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', + { defaultMessage: 'Shared sources' } +); + +const USERS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', + { defaultMessage: 'Users & invitations' } +); + +const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', + { defaultMessage: 'Add shared sources for your organization to start searching.' } +); + +const USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', + { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } +); + +const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', + { defaultMessage: 'Invite your colleagues into this organization to search with you.' } +); + +export const OnboardingSteps: React.FC = () => { + const { + isFederatedAuth, + organization: { name, defaultOrgName }, + account: { isCurated, canCreateInvitations }, + } = useValues(AppLogic); + + const { + hasUsers, + hasOrgSources, + canCreateContentSources, + accountsCount, + sourcesCount, + } = useValues(OverviewLogic); + + const accountsPath = + !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? SOURCES_PATH : undefined; + + const SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', + { + defaultMessage: + 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', + values: { sourcesCount }, + } + ); + + return ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +export const OrgNameOnboarding: React.FC = () => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'org_name_change_button', + }); + + const buttonProps = { + onClick, + target: '_blank', + color: 'primary', + href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), + 'data-test-subj': 'orgNameChangeButton', + } as EuiButtonEmptyProps & EuiLinkProps; + + return ( + + + + + + + +

+ +

+
+
+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx new file mode 100644 index 00000000000000..412977f18fadfb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlexGrid } from '@elastic/eui'; + +import { setMockValues } from './__mocks__'; +import { OrganizationStats } from './organization_stats'; +import { StatisticCard } from './statistic_card'; + +describe('OrganizationStats', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + setMockValues({ isFederatedAuth: false }); + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx new file mode 100644 index 00000000000000..525035030b8ccb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/organization_stats.tsx @@ -0,0 +1,79 @@ +/* + * 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 { useValues } from 'kea'; + +import { EuiFlexGrid } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { AppLogic } from '../../app_logic'; +import { ContentSection } from '../../components/shared/content_section'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { OverviewLogic } from './overview_logic'; +import { StatisticCard } from './statistic_card'; + +export const OrganizationStats: React.FC = () => { + const { isFederatedAuth } = useValues(AppLogic); + + const { sourcesCount, pendingInvitationsCount, accountsCount, personalSourcesCount } = useValues( + OverviewLogic + ); + + return ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx new file mode 100644 index 00000000000000..2ec2d949ff491a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 '../../../__mocks__/react_router_history.mock'; +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { Loading } from '../../../shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { mockActions, setMockValues } from './__mocks__'; +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { Overview } from './overview'; +import { RecentActivity } from './recent_activity'; + +describe('Overview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('calls initialize function', async () => { + mount(); + + expect(mockActions.initializeOverview).toHaveBeenCalled(); + }); + + it('renders onboarding state', () => { + setMockValues({ dataLoading: false }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(1); + expect(wrapper.find(OrganizationStats)).toHaveLength(1); + expect(wrapper.find(RecentActivity)).toHaveLength(1); + }); + + it('renders when onboarding complete', () => { + setMockValues({ + dataLoading: false, + hasUsers: true, + hasOrgSources: true, + isOldAccount: true, + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx new file mode 100644 index 00000000000000..6bf84b585da808 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove EuiPage & EuiPageBody before exposing full app + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import { ProductButton } from '../../components/shared/product_button'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { OverviewLogic } from './overview_logic'; +import { RecentActivity } from './recent_activity'; + +const ONBOARDING_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', + { defaultMessage: 'Get started with Workplace Search' } +); + +const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { + defaultMessage: 'Organization overview', +}); + +const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', + { defaultMessage: 'Complete the following to set up your organization.' } +); + +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', + { defaultMessage: "Your organizations's statistics and activity" } +); + +export const Overview: React.FC = () => { + const { + organization: { name: orgName, defaultOrgName }, + } = useValues(AppLogic); + + const { initializeOverview } = useActions(OverviewLogic); + const { dataLoading, hasUsers, hasOrgSources, isOldAccount } = useValues(OverviewLogic); + + useEffect(() => { + initializeOverview(); + }, [initializeOverview]); + + // TODO: Remove div wrapper once the Overview page is using the full Layout + if (dataLoading) { + return ( +
+ +
+ ); + } + + const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; + + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + + return ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts new file mode 100644 index 00000000000000..0e84315104343b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues } from '../../../__mocks__'; + +import { mockOverviewValues } from './__mocks__'; +import { OverviewLogic } from './overview_logic'; + +describe('OverviewLogic', () => { + const { mount } = new LogicMounter(OverviewLogic); + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(OverviewLogic.values).toEqual(mockOverviewValues); + }); + + describe('setServerData', () => { + const feed = [{ foo: 'bar' }] as any; + + const data = { + accountsCount: 1, + activityFeed: feed, + canCreateContentSources: true, + hasOrgSources: true, + hasUsers: true, + isOldAccount: true, + pendingInvitationsCount: 1, + personalSourcesCount: 1, + sourcesCount: 1, + }; + + beforeEach(() => { + OverviewLogic.actions.setServerData(data); + }); + + it('will set `dataLoading` to false', () => { + expect(OverviewLogic.values.dataLoading).toEqual(false); + }); + + it('will set server values', () => { + expect(OverviewLogic.values.hasUsers).toEqual(true); + expect(OverviewLogic.values.hasOrgSources).toEqual(true); + expect(OverviewLogic.values.canCreateContentSources).toEqual(true); + expect(OverviewLogic.values.isOldAccount).toEqual(true); + expect(OverviewLogic.values.sourcesCount).toEqual(1); + expect(OverviewLogic.values.pendingInvitationsCount).toEqual(1); + expect(OverviewLogic.values.accountsCount).toEqual(1); + expect(OverviewLogic.values.personalSourcesCount).toEqual(1); + expect(OverviewLogic.values.activityFeed).toEqual(feed); + }); + }); + + describe('initializeOverview', () => { + it('calls API and sets values', async () => { + const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData'); + + await OverviewLogic.actions.initializeOverview(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/overview'); + expect(setServerDataSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts new file mode 100644 index 00000000000000..7d8bc955294831 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/overview_logic.ts @@ -0,0 +1,114 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; + +import { FeedActivity } from './recent_activity'; + +interface OverviewServerData { + hasUsers: boolean; + hasOrgSources: boolean; + canCreateContentSources: boolean; + isOldAccount: boolean; + sourcesCount: number; + pendingInvitationsCount: number; + accountsCount: number; + personalSourcesCount: number; + activityFeed: FeedActivity[]; +} + +interface OverviewActions { + setServerData(serverData: OverviewServerData): OverviewServerData; + initializeOverview(): void; +} + +interface OverviewValues extends OverviewServerData { + dataLoading: boolean; +} + +export const OverviewLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'overview_logic'], + actions: { + setServerData: (serverData) => serverData, + initializeOverview: () => null, + }, + reducers: { + hasUsers: [ + false, + { + setServerData: (_, { hasUsers }) => hasUsers, + }, + ], + hasOrgSources: [ + false, + { + setServerData: (_, { hasOrgSources }) => hasOrgSources, + }, + ], + canCreateContentSources: [ + false, + { + setServerData: (_, { canCreateContentSources }) => canCreateContentSources, + }, + ], + isOldAccount: [ + false, + { + setServerData: (_, { isOldAccount }) => isOldAccount, + }, + ], + sourcesCount: [ + 0, + { + setServerData: (_, { sourcesCount }) => sourcesCount, + }, + ], + pendingInvitationsCount: [ + 0, + { + setServerData: (_, { pendingInvitationsCount }) => pendingInvitationsCount, + }, + ], + accountsCount: [ + 0, + { + setServerData: (_, { accountsCount }) => accountsCount, + }, + ], + personalSourcesCount: [ + 0, + { + setServerData: (_, { personalSourcesCount }) => personalSourcesCount, + }, + ], + activityFeed: [ + [], + { + setServerData: (_, { activityFeed }) => activityFeed, + }, + ], + dataLoading: [ + true, + { + setServerData: () => false, + }, + ], + }, + listeners: ({ actions }) => ({ + initializeOverview: async () => { + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/overview'); + actions.setServerData(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss new file mode 100644 index 00000000000000..822ba64c91237b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.scss @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +.activity { + display: flex; + justify-content: space-between; + padding: $euiSizeM; + font-size: $euiFontSizeS; + + &--error { + font-weight: $euiFontWeightSemiBold; + color: $euiColorDanger; + background: rgba($euiColorDanger, .1); + + &__label { + margin-left: $euiSizeS * 1.75; + font-weight: $euiFontWeightRegular; + text-decoration: underline; + opacity: .7; + } + } + + &__message { + flex-grow: 1; + } + + &__date { + flex-grow: 0; + } + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx new file mode 100644 index 00000000000000..0b62207afc5206 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { mockTelemetryActions } from '../../../__mocks__'; + +import './__mocks__/overview_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { setMockValues } from './__mocks__'; +import { RecentActivity, RecentActivityItem } from './recent_activity'; + +const organization = { name: 'foo', defaultOrgName: 'bar' }; + +const activityFeed = [ + { + id: 'demo', + sourceId: 'd2d2d23d', + message: 'was successfully connected', + target: 'http://localhost:3002/ws/org/sources', + timestamp: '2020-06-24 16:34:16', + }, +]; + +describe('RecentActivity', () => { + it('renders with no activityFeed data', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + setMockValues({ organization }); + shallow(); + }); + + it('renders an activityFeed with links', () => { + setMockValues({ activityFeed }); + const wrapper = shallow(); + const activity = wrapper.find(RecentActivityItem).dive(); + + expect(activity).toHaveLength(1); + + const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + link.simulate('click'); + expect(mockTelemetryActions.sendWorkplaceSearchTelemetry).toHaveBeenCalled(); + }); + + it('renders activity item error state', () => { + const props = { ...activityFeed[0], status: 'error' }; + const wrapper = shallow(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); + + it('renders recent activity message for default org name', () => { + setMockValues({ + organization: { + name: 'foo', + defaultOrgName: 'foo', + }, + }); + const wrapper = shallow(); + const emptyPrompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(emptyPrompt.find(FormattedMessage).prop('defaultMessage')).toEqual( + 'Your organization has no recent activity' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx new file mode 100644 index 00000000000000..43d3f880feef45 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/recent_activity.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; +import moment from 'moment'; + +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { TelemetryLogic } from '../../../shared/telemetry'; +import { AppLogic } from '../../app_logic'; +import { ContentSection } from '../../components/shared/content_section'; +import { RECENT_ACTIVITY_TITLE } from '../../constants'; +import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; + +import { OverviewLogic } from './overview_logic'; + +import './recent_activity.scss'; + +export interface FeedActivity { + status?: string; + id: string; + message: string; + timestamp: string; + sourceId: string; +} + +export const RecentActivity: React.FC = () => { + const { + organization: { name, defaultOrgName }, + } = useValues(AppLogic); + + const { activityFeed } = useValues(OverviewLogic); + + return ( + + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: FeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + id, + status, + message, + timestamp, + sourceId, +}) => { + const { sendWorkplaceSearchTelemetry } = useActions(TelemetryLogic); + + const onClick = () => + sendWorkplaceSearchTelemetry({ + action: 'clicked', + metric: 'recent_activity_source_details_link', + }); + + const linkProps = { + onClick, + target: '_blank', + href: getWorkplaceSearchUrl(getContentSourcePath(SOURCE_DETAILS_PATH, sourceId, true)), + external: true, + color: status === 'error' ? 'danger' : 'primary', + 'data-test-subj': 'viewSourceDetailsLink', + } as EuiLinkProps; + + return ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx new file mode 100644 index 00000000000000..ff1d69e4068305 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/enterprise_search_url.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; + +import { StatisticCard } from './statistic_card'; + +const props = { + title: 'foo', +}; + +describe('StatisticCard', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx new file mode 100644 index 00000000000000..346debb1c52519 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview_mvp/statistic_card.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; + +interface StatisticCardProps { + title: string; + count?: number; + actionPath?: string; +} + +export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { + const linkProps = actionPath + ? { + href: getWorkplaceSearchUrl(actionPath), + target: '_blank', + rel: 'noopener', + } + : {}; + // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) + + return ( + + + {count} + + } + /> + + ); +}; From b81967e4d4a5f233c2c467fb34388caf161bc116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Thu, 11 Feb 2021 17:37:10 +0100 Subject: [PATCH 05/26] Uses doc link service in Painless lab (#90433) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...na-plugin-core-public.doclinksstart.links.md | 3 +++ .../kibana-plugin-core-public.doclinksstart.md | 2 +- src/core/public/doc_links/doc_links_service.ts | 6 ++++++ src/core/public/public.api.md | 3 +++ x-pack/plugins/painless_lab/public/links.ts | 17 ++++++++--------- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index fd46a8a0f82c12..017e3ec57d3404 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -91,7 +91,9 @@ readonly links: { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -131,6 +133,7 @@ readonly links: { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 5be8f8ce7e8c7e..f206a914aef97c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index da35373f57322a..0d40899544c085 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -110,7 +110,9 @@ export class DocLinksService { scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html#_values_source`, painless: `${ELASTICSEARCH_DOCS}modules-scripting-painless.html`, painlessApi: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, painlessSyntax: `${ELASTICSEARCH_DOCS}modules-scripting-painless-syntax.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, painlessLanguage: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, luceneExpressions: `${ELASTICSEARCH_DOCS}modules-scripting-expression.html`, }, @@ -239,6 +241,7 @@ export class DocLinksService { openIndex: `${ELASTICSEARCH_DOCS}indices-open-close.html`, putComponentTemplate: `${ELASTICSEARCH_DOCS}indices-component-template.html`, painlessExecute: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, putComponentTemplateMetadata: `${ELASTICSEARCH_DOCS}indices-component-template.html#component-templates-metadata`, putWatch: `${ELASTICSEARCH_DOCS}/watcher-api-put-watch.html`, updateTransform: `${ELASTICSEARCH_DOCS}update-transform.html`, @@ -336,7 +339,9 @@ export interface DocLinksStart { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -376,6 +381,7 @@ export interface DocLinksStart { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b4a2c40f3003b6..2922606ac3e1e1 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -561,7 +561,9 @@ export interface DocLinksStart { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -601,6 +603,7 @@ export interface DocLinksStart { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; diff --git a/x-pack/plugins/painless_lab/public/links.ts b/x-pack/plugins/painless_lab/public/links.ts index 1de97d6a193c29..f8c4b55e521ec8 100644 --- a/x-pack/plugins/painless_lab/public/links.ts +++ b/x-pack/plugins/painless_lab/public/links.ts @@ -9,14 +9,13 @@ import { DocLinksStart } from 'src/core/public'; export type Links = ReturnType; -// eslint-disable-next-line @typescript-eslint/naming-convention -export const getLinks = ({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL }: DocLinksStart) => +export const getLinks = ({ links }: DocLinksStart) => Object.freeze({ - painlessExecuteAPI: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, - painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, - painlessAPIReference: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, - painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, - painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, - esQueryDSL: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, - modulesScriptingPreferParams: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/modules-scripting-using.html#prefer-params`, + painlessExecuteAPI: links.apis.painlessExecute, + painlessExecuteAPIContexts: links.apis.painlessExecuteAPIContexts, + painlessAPIReference: links.scriptedFields.painlessApi, + painlessWalkthrough: links.scriptedFields.painlessWalkthrough, + painlessLangSpec: links.scriptedFields.painlessLangSpec, + esQueryDSL: links.query.queryDsl, + modulesScriptingPreferParams: links.elasticsearch.scriptParameters, }); From 9da625b31d43035a5a5979e874cd5e6e3035d44c Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 11 Feb 2021 17:50:01 +0100 Subject: [PATCH 06/26] [UiActions] fix race condition registering actions (#90944) --- src/plugins/data/public/plugin.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 5557a30fd4046c..39d3ca57215b74 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -101,25 +101,10 @@ export class DataPublicPlugin }); uiActions.registerTrigger(applyFilterTrigger); - uiActions.registerAction( createFilterAction(queryService.filterManager, queryService.timefilter.timefilter) ); - uiActions.addTriggerAction( - 'SELECT_RANGE_TRIGGER', - createSelectRangeAction(() => ({ - uiActions: startServices().plugins.uiActions, - })) - ); - - uiActions.addTriggerAction( - 'VALUE_CLICK_TRIGGER', - createValueClickAction(() => ({ - uiActions: startServices().plugins.uiActions, - })) - ); - inspector.registerView( getTableViewDescription(() => ({ uiActions: startServices().plugins.uiActions, @@ -174,6 +159,20 @@ export class DataPublicPlugin const search = this.searchService.start(core, { fieldFormats, indexPatterns }); setSearchService(search); + uiActions.addTriggerAction( + 'SELECT_RANGE_TRIGGER', + createSelectRangeAction(() => ({ + uiActions, + })) + ); + + uiActions.addTriggerAction( + 'VALUE_CLICK_TRIGGER', + createValueClickAction(() => ({ + uiActions, + })) + ); + uiActions.addTriggerAction( APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER) From 40570a633f87067f358832499d4d22e6ffbe1eee Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 11 Feb 2021 17:56:29 +0100 Subject: [PATCH 07/26] render only once (#90601) --- .../embeddable/embeddable.test.tsx | 13 ++++++++++--- .../editor_frame_service/embeddable/embeddable.tsx | 3 --- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index c4edadc095b61d..d2085a4cc8a8b1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -104,7 +104,7 @@ describe('embeddable', () => { mountpoint.remove(); }); - it('should render expression with expression renderer', async () => { + it('should render expression once with expression renderer', async () => { const embeddable = new Embeddable( { timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, @@ -123,11 +123,18 @@ describe('embeddable', () => { ], }), }, - {} as LensEmbeddableInput + { + timeRange: { + from: 'now-15m', + to: 'now', + }, + } as LensEmbeddableInput ); - await embeddable.initializeSavedVis({} as LensEmbeddableInput); embeddable.render(mountpoint); + // wait one tick to give embeddable time to initialize + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(expressionRenderer).toHaveBeenCalledTimes(1); expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(`my | expression`); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index d66d186477cc7e..dc5f9b366e6b52 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -229,9 +229,6 @@ export class Embeddable this.expression = expression ? toExpression(expression) : null; await this.initializeOutput(); this.isInitialized = true; - if (this.domNode) { - this.render(this.domNode); - } } onContainerStateChanged(containerState: LensEmbeddableInput) { From 341e9cf2eb557f41b8e34d8d2c8824a7556e0557 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 11 Feb 2021 18:14:14 +0100 Subject: [PATCH 08/26] [ML] Anomaly Detection alert type (#89286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ML] init ML alerts * [ML] job selector * [ML] move schema server-side * [ML] fix type 🤦‍ * [ML] severity selector * [ML] add alerting capabilities * [ML] add alerting capabilities * [ML] result type selector * [ML] time range selector * [ML] init alert preview endpoint * [ML] update SeveritySelector component * [ML] adjust the form * [ML] adjust the form * [ML] server-side, preview component * [ML] update defaultMessage * [ML] Anomaly explorer URL * [ML] validate preview interval * [ML] rename alert type * [ML] fix i18n * [ML] fix TS and mocks * [ML] update licence headers * [ML] add ts config references * [ML] init functional tests * [ML] functional test for creating anomaly detection alert * [ML] adjust bucket results query * [ML] fix messages * [ML] resolve functional tests related issues * [ML] fix result check * [ML] change preview layout * [ML] extend ml client types * [ML] add missing types, remove unused client variable * [ML] change to import type * [ML] handle preview error * [ML] move error callout * [ML] better error handling * [ML] add client-side validation * [ML] move fake request to the executor * [ML] revert ml client type changes, set response type manually * [ML] documentationUrl * [ML] add extra sentence for interim results * [ML] use publicBaseUrl * [ML] adjust the query * [ML] fix anomaly explorer url * [ML] adjust the alert params schema * [ML] remove default severity threshold for records and influencers * [ML] fix query with filter block * [ML] fix functional tests * [ML] remove isInterim check * [ML] remove redundant fragment * [ML] fix selected cells hook * [ML] set query string * [ML] support sample size by the preview endpoint * [ML] update counter * [ML] add check for the bucket span * [ML] fix effects * [ML] disable mlExplorerSwimlane * [ML] refactor functional tests to use setSliderValue * [ML] add assertTestIntervalValue * [ML] floor scores --- x-pack/plugins/alerts/server/types.ts | 1 + x-pack/plugins/ml/common/constants/alerts.ts | 49 ++ .../plugins/ml/common/constants/anomalies.ts | 6 + x-pack/plugins/ml/common/constants/app.ts | 1 + x-pack/plugins/ml/common/types/alerts.ts | 92 +++ x-pack/plugins/ml/common/types/anomalies.ts | 4 +- .../plugins/ml/common/types/capabilities.ts | 9 + x-pack/plugins/ml/common/util/validators.ts | 20 +- x-pack/plugins/ml/kibana.json | 4 +- .../ml/public/alerting/job_selector.tsx | 124 +++++ .../alerting/ml_anomaly_alert_trigger.tsx | 88 +++ .../alerting/preview_alert_condition.tsx | 294 ++++++++++ .../ml/public/alerting/register_ml_alerts.ts | 93 ++++ .../public/alerting/result_type_selector.tsx | 97 ++++ .../public/alerting/severity_control/index.ts | 8 + .../severity_control/severity_control.tsx | 84 +++ .../alerting/severity_control/styles.scss | 18 + .../select_severity/select_severity.tsx | 27 +- .../explorer/hooks/use_selected_cells.ts | 4 +- .../jobs/new_job/common/job_validator/util.ts | 2 +- .../services/ml_api_service/alerting.ts | 29 + .../services/ml_api_service/jobs.ts | 64 +-- x-pack/plugins/ml/public/plugin.ts | 12 +- .../lib/alerts/alerting_service.test.ts | 14 + .../ml/server/lib/alerts/alerting_service.ts | 525 ++++++++++++++++++ .../register_anomaly_detection_alert_type.ts | 141 +++++ .../server/lib/alerts/register_ml_alerts.ts | 20 + x-pack/plugins/ml/server/plugin.ts | 38 +- x-pack/plugins/ml/server/routes/alerting.ts | 45 ++ .../server/routes/schemas/alerting_schema.ts | 48 ++ .../providers/alerting_service.ts | 38 ++ .../shared_services/providers/job_service.ts | 2 +- .../server/shared_services/shared_services.ts | 8 +- x-pack/plugins/ml/server/types.ts | 4 + x-pack/plugins/ml/tsconfig.json | 2 + .../signals/signal_rule_alert_type.test.ts | 1 + .../sections/alert_form/alert_form.tsx | 1 + .../classification_creation.ts | 2 +- .../regression_creation.ts | 2 +- .../test/functional/services/ml/alerting.ts | 104 ++++ .../test/functional/services/ml/common_ui.ts | 49 ++ .../ml/data_frame_analytics_creation.ts | 45 +- x-pack/test/functional/services/ml/index.ts | 3 + .../test/functional/services/ml/navigation.ts | 6 + .../apps/ml/alert_flyout.ts | 124 +++++ .../functional_with_es_ssl/apps/ml/index.ts | 33 ++ x-pack/test/functional_with_es_ssl/config.ts | 1 + .../page_objects/triggers_actions_ui_page.ts | 29 + 48 files changed, 2305 insertions(+), 110 deletions(-) create mode 100644 x-pack/plugins/ml/common/constants/alerts.ts create mode 100644 x-pack/plugins/ml/common/types/alerts.ts create mode 100644 x-pack/plugins/ml/public/alerting/job_selector.tsx create mode 100644 x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx create mode 100644 x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx create mode 100644 x-pack/plugins/ml/public/alerting/register_ml_alerts.ts create mode 100644 x-pack/plugins/ml/public/alerting/result_type_selector.tsx create mode 100644 x-pack/plugins/ml/public/alerting/severity_control/index.ts create mode 100644 x-pack/plugins/ml/public/alerting/severity_control/severity_control.tsx create mode 100644 x-pack/plugins/ml/public/alerting/severity_control/styles.scss create mode 100644 x-pack/plugins/ml/public/application/services/ml_api_service/alerting.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/alerting_service.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts create mode 100644 x-pack/plugins/ml/server/routes/alerting.ts create mode 100644 x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts create mode 100644 x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts create mode 100644 x-pack/test/functional/services/ml/alerting.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/ml/index.ts diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 8dbebbdc75e802..fd9bdb09f2c457 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -105,6 +105,7 @@ export interface AlertExecutorOptions< export interface ActionVariable { name: string; description: string; + useWithTripleBracesInTemplates?: boolean; } export type ExecutorType< diff --git a/x-pack/plugins/ml/common/constants/alerts.ts b/x-pack/plugins/ml/common/constants/alerts.ts new file mode 100644 index 00000000000000..55d0d0cc0cc565 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/alerts.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ActionGroup } from '../../../alerts/common'; +import { MINIMUM_FULL_LICENSE } from '../license'; +import { PLUGIN_ID } from './app'; + +export const ML_ALERT_TYPES = { + ANOMALY_DETECTION: 'xpack.ml.anomaly_detection_alert', +} as const; + +export type MlAlertType = typeof ML_ALERT_TYPES[keyof typeof ML_ALERT_TYPES]; + +export const ANOMALY_SCORE_MATCH_GROUP_ID = 'anomaly_score_match'; +export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID; +export const THRESHOLD_MET_GROUP: ActionGroup = { + id: ANOMALY_SCORE_MATCH_GROUP_ID, + name: i18n.translate('xpack.ml.anomalyDetectionAlert.actionGroupName', { + defaultMessage: 'Anomaly score matched the condition', + }), +}; + +export const ML_ALERT_TYPES_CONFIG: Record< + MlAlertType, + { + name: string; + actionGroups: Array>; + defaultActionGroupId: AnomalyScoreMatchGroupId; + minimumLicenseRequired: string; + producer: string; + } +> = { + [ML_ALERT_TYPES.ANOMALY_DETECTION]: { + name: i18n.translate('xpack.ml.anomalyDetectionAlert.name', { + defaultMessage: 'Anomaly detection alert', + }), + actionGroups: [THRESHOLD_MET_GROUP], + defaultActionGroupId: ANOMALY_SCORE_MATCH_GROUP_ID, + minimumLicenseRequired: MINIMUM_FULL_LICENSE, + producer: PLUGIN_ID, + }, +}; + +export const ALERT_PREVIEW_SAMPLE_SIZE = 5; diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts index f9e12cd720bc7b..5cca321482a000 100644 --- a/x-pack/plugins/ml/common/constants/anomalies.ts +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -31,6 +31,12 @@ export const SEVERITY_COLORS = { BLANK: '#ffffff', }; +export const ANOMALY_RESULT_TYPE = { + BUCKET: 'bucket', + RECORD: 'record', + INFLUENCER: 'influencer', +} as const; + export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; export const JOB_ID = 'job_id'; export const PARTITION_FIELD_VALUE = 'partition_field_value'; diff --git a/x-pack/plugins/ml/common/constants/app.ts b/x-pack/plugins/ml/common/constants/app.ts index 498cf6a6e7e7f7..974984d457ae49 100644 --- a/x-pack/plugins/ml/common/constants/app.ts +++ b/x-pack/plugins/ml/common/constants/app.ts @@ -13,3 +13,4 @@ export const PLUGIN_ICON_SOLUTION = 'logoKibana'; export const ML_APP_NAME = i18n.translate('xpack.ml.navMenu.mlAppNameText', { defaultMessage: 'Machine Learning', }); +export const ML_BASE_PATH = '/api/ml'; diff --git a/x-pack/plugins/ml/common/types/alerts.ts b/x-pack/plugins/ml/common/types/alerts.ts new file mode 100644 index 00000000000000..d19385a175efd9 --- /dev/null +++ b/x-pack/plugins/ml/common/types/alerts.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnomalyResultType } from './anomalies'; +import { ANOMALY_RESULT_TYPE } from '../constants/anomalies'; +import { AlertTypeParams } from '../../../alerts/common'; + +export type PreviewResultsKeys = 'record_results' | 'bucket_results' | 'influencer_results'; +export type TopHitsResultsKeys = 'top_record_hits' | 'top_bucket_hits' | 'top_influencer_hits'; + +export interface AlertExecutionResult { + count: number; + key: number; + key_as_string: string; + isInterim: boolean; + jobIds: string[]; + timestamp: number; + timestampEpoch: number; + timestampIso8601: string; + score: number; + bucketRange: { start: string; end: string }; + topRecords: RecordAnomalyAlertDoc[]; + topInfluencers?: InfluencerAnomalyAlertDoc[]; +} + +export interface PreviewResponse { + count: number; + results: AlertExecutionResult[]; +} + +interface BaseAnomalyAlertDoc { + result_type: AnomalyResultType; + job_id: string; + /** + * Rounded score + */ + score: number; + timestamp: number; + is_interim: boolean; + unique_key: string; +} + +export interface RecordAnomalyAlertDoc extends BaseAnomalyAlertDoc { + result_type: typeof ANOMALY_RESULT_TYPE.RECORD; + function: string; + field_name: string; + by_field_value: string | number; + over_field_value: string | number; + partition_field_value: string | number; +} + +export interface BucketAnomalyAlertDoc extends BaseAnomalyAlertDoc { + result_type: typeof ANOMALY_RESULT_TYPE.BUCKET; + start: number; + end: number; + timestamp_epoch: number; + timestamp_iso8601: number; +} + +export interface InfluencerAnomalyAlertDoc extends BaseAnomalyAlertDoc { + result_type: typeof ANOMALY_RESULT_TYPE.INFLUENCER; + influencer_field_name: string; + influencer_field_value: string | number; + influencer_score: number; +} + +export type AlertHitDoc = RecordAnomalyAlertDoc | BucketAnomalyAlertDoc | InfluencerAnomalyAlertDoc; + +export function isRecordAnomalyAlertDoc(arg: any): arg is RecordAnomalyAlertDoc { + return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.RECORD; +} + +export function isBucketAnomalyAlertDoc(arg: any): arg is BucketAnomalyAlertDoc { + return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.BUCKET; +} + +export function isInfluencerAnomalyAlertDoc(arg: any): arg is InfluencerAnomalyAlertDoc { + return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.INFLUENCER; +} + +export type MlAnomalyDetectionAlertParams = { + jobSelection: { + jobIds?: string[]; + groupIds?: string[]; + }; + severity: number; + resultType: AnomalyResultType; +} & AlertTypeParams; diff --git a/x-pack/plugins/ml/common/types/anomalies.ts b/x-pack/plugins/ml/common/types/anomalies.ts index bdc7fddb18b68a..e84035aa50c8f8 100644 --- a/x-pack/plugins/ml/common/types/anomalies.ts +++ b/x-pack/plugins/ml/common/types/anomalies.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PARTITION_FIELDS } from '../constants/anomalies'; +import { PARTITION_FIELDS, ANOMALY_RESULT_TYPE } from '../constants/anomalies'; export interface Influencer { influencer_field_name: string; @@ -77,3 +77,5 @@ export interface AnomalyCategorizerStatsDoc { } export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field'; + +export type AnomalyResultType = typeof ANOMALY_RESULT_TYPE[keyof typeof ANOMALY_RESULT_TYPE]; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index 974a1f2243060a..cccf87f0a7950d 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -8,6 +8,7 @@ import { KibanaRequest } from 'kibana/server'; import { PLUGIN_ID } from '../constants/app'; import { ML_SAVED_OBJECT_TYPE } from './saved_objects'; +import { ML_ALERT_TYPES } from '../constants/alerts'; export const apmUserMlCapabilities = { canGetJobs: false, @@ -106,6 +107,10 @@ export function getPluginPrivileges() { all: savedObjects, read: savedObjects, }, + alerting: { + all: Object.values(ML_ALERT_TYPES), + read: [], + }, }, user: { ...privilege, @@ -117,6 +122,10 @@ export function getPluginPrivileges() { all: [], read: savedObjects, }, + alerting: { + all: [], + read: Object.values(ML_ALERT_TYPES), + }, }, apmUser: { excludeFromBasePrivileges: true, diff --git a/x-pack/plugins/ml/common/util/validators.ts b/x-pack/plugins/ml/common/util/validators.ts index 62727c9941a00e..b52e82495a76cc 100644 --- a/x-pack/plugins/ml/common/util/validators.ts +++ b/x-pack/plugins/ml/common/util/validators.ts @@ -6,6 +6,7 @@ */ import { ALLOWED_DATA_UNITS } from '../constants/validation'; +import { parseInterval } from './parse_interval'; /** * Provides a validator function for maximum allowed input length. @@ -61,17 +62,17 @@ export function composeValidators( } export function requiredValidator() { - return (value: any) => { + return (value: T) => { return value === '' || value === undefined || value === null ? { required: true } : null; }; } -export type ValidationResult = object | null; +export type ValidationResult = Record | null; export type MemoryInputValidatorResult = { invalidUnits: { allowedUnits: string } } | null; export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) { - return (value: any) => { + return (value: T) => { if (typeof value !== 'string' || value === '') { return null; } @@ -81,3 +82,16 @@ export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) { : { invalidUnits: { allowedUnits: allowedUnits.join(', ') } }; }; } + +export function timeIntervalInputValidator() { + return (value: string) => { + const r = parseInterval(value); + if (r === null) { + return { + invalidTimeInterval: true, + }; + } + + return null; + }; +} diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index a73a68445a391f..790c9a28b656c9 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -17,9 +17,11 @@ "uiActions", "kibanaLegacy", "indexPatternManagement", - "discover" + "discover", + "triggersActionsUi" ], "optionalPlugins": [ + "alerts", "home", "security", "spaces", diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx new file mode 100644 index 00000000000000..969ed5af79107e --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps, EuiFormRow } from '@elastic/eui'; +import { JobId } from '../../common/types/anomaly_detection_jobs'; +import { MlApiServices } from '../application/services/ml_api_service'; + +interface JobSelection { + jobIds?: JobId[]; + groupIds?: string[]; +} + +export interface JobSelectorControlProps { + jobSelection?: JobSelection; + onSelectionChange: (jobSelection: JobSelection) => void; + adJobsApiService: MlApiServices['jobs']; + /** + * Validation is handled by alerting framework + */ + errors: string[]; +} + +export const JobSelectorControl: FC = ({ + jobSelection, + onSelectionChange, + adJobsApiService, + errors, +}) => { + const [options, setOptions] = useState>>([]); + const jobIds = useMemo(() => new Set(), []); + const groupIds = useMemo(() => new Set(), []); + + const fetchOptions = useCallback(async () => { + try { + const { + jobIds: jobIdOptions, + groupIds: groupIdOptions, + } = await adJobsApiService.getAllJobAndGroupIds(); + + jobIdOptions.forEach((v) => { + jobIds.add(v); + }); + groupIdOptions.forEach((v) => { + groupIds.add(v); + }); + + setOptions([ + { + label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', { + defaultMessage: 'Jobs', + }), + options: jobIdOptions.map((v) => ({ label: v })), + }, + { + label: i18n.translate('xpack.ml.jobSelector.groupOptionsLabel', { + defaultMessage: 'Groups', + }), + options: groupIdOptions.map((v) => ({ label: v })), + }, + ]); + } catch (e) { + // TODO add error handling + } + }, [adJobsApiService]); + + const onChange: EuiComboBoxProps['onChange'] = useCallback( + (selectedOptions) => { + const selectedJobIds: JobId[] = []; + const selectedGroupIds: string[] = []; + selectedOptions.forEach(({ label }: { label: string }) => { + if (jobIds.has(label)) { + selectedJobIds.push(label); + } else if (groupIds.has(label)) { + selectedGroupIds.push(label); + } + }); + onSelectionChange({ + ...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}), + ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), + }); + }, + [jobIds, groupIds] + ); + + useEffect(() => { + fetchOptions(); + }, []); + + const selectedOptions = Object.values(jobSelection ?? {}) + .flat() + .map((v) => ({ + label: v, + })); + + return ( + + } + isInvalid={!!errors?.length} + error={errors} + > + + selectedOptions={selectedOptions} + options={options} + onChange={onChange} + fullWidth + data-test-subj={'mlAnomalyAlertJobSelection'} + isInvalid={!!errors?.length} + /> + + ); +}; diff --git a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx new file mode 100644 index 00000000000000..5991a603890d71 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import { EuiSpacer, EuiForm } from '@elastic/eui'; +import { JobSelectorControl } from './job_selector'; +import { useMlKibana } from '../application/contexts/kibana'; +import { jobsApiProvider } from '../application/services/ml_api_service/jobs'; +import { HttpService } from '../application/services/http_service'; +import { SeverityControl } from './severity_control'; +import { ResultTypeSelector } from './result_type_selector'; +import { alertingApiProvider } from '../application/services/ml_api_service/alerting'; +import { PreviewAlertCondition } from './preview_alert_condition'; +import { ANOMALY_THRESHOLD } from '../../common'; +import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; +import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies'; + +interface MlAnomalyAlertTriggerProps { + alertParams: MlAnomalyDetectionAlertParams; + setAlertParams: ( + key: T, + value: MlAnomalyDetectionAlertParams[T] + ) => void; + errors: Record; +} + +const MlAnomalyAlertTrigger: FC = ({ + alertParams, + setAlertParams, + errors, +}) => { + const { + services: { http }, + } = useMlKibana(); + const mlHttpService = useMemo(() => new HttpService(http), [http]); + const adJobsApiService = useMemo(() => jobsApiProvider(mlHttpService), [mlHttpService]); + const alertingApiService = useMemo(() => alertingApiProvider(mlHttpService), [mlHttpService]); + + const onAlertParamChange = useCallback( + (param: T) => ( + update: MlAnomalyDetectionAlertParams[T] + ) => { + setAlertParams(param, update); + }, + [] + ); + + useEffect(function setDefaults() { + if (alertParams.severity === undefined) { + onAlertParamChange('severity')(ANOMALY_THRESHOLD.CRITICAL); + } + if (alertParams.resultType === undefined) { + onAlertParamChange('resultType')(ANOMALY_RESULT_TYPE.BUCKET); + } + }, []); + + return ( + + + + + + + + + + ); +}; + +// Default export is required for React.lazy loading +// +// eslint-disable-next-line import/no-default-export +export default MlAnomalyAlertTrigger; diff --git a/x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx b/x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx new file mode 100644 index 00000000000000..ca5d354117b11a --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCode, + EuiDescriptionList, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import type { AlertingApiService } from '../application/services/ml_api_service/alerting'; +import { MlAnomalyDetectionAlertParams, PreviewResponse } from '../../common/types/alerts'; +import { composeValidators } from '../../common'; +import { requiredValidator, timeIntervalInputValidator } from '../../common/util/validators'; +import { invalidTimeIntervalMessage } from '../application/jobs/new_job/common/job_validator/util'; +import { ALERT_PREVIEW_SAMPLE_SIZE } from '../../common/constants/alerts'; + +export interface PreviewAlertConditionProps { + alertingApiService: AlertingApiService; + alertParams: MlAnomalyDetectionAlertParams; +} + +const AlertInstancePreview: FC = React.memo( + ({ jobIds, timestampIso8601, score, topInfluencers, topRecords }) => { + const listItems = [ + { + title: i18n.translate('xpack.ml.previewAlert.jobsLabel', { + defaultMessage: 'Job IDs:', + }), + description: jobIds.join(', '), + }, + { + title: i18n.translate('xpack.ml.previewAlert.timeLabel', { + defaultMessage: 'Time: ', + }), + description: timestampIso8601, + }, + { + title: i18n.translate('xpack.ml.previewAlert.scoreLabel', { + defaultMessage: 'Anomaly score:', + }), + description: score, + }, + ...(topInfluencers && topInfluencers.length > 0 + ? [ + { + title: i18n.translate('xpack.ml.previewAlert.topInfluencersLabel', { + defaultMessage: 'Top influencers:', + }), + description: ( +
    + {topInfluencers.map((i) => ( +
  • + {i.influencer_field_name} ={' '} + {i.influencer_field_value} [{i.score}] +
  • + ))} +
+ ), + }, + ] + : []), + ...(topRecords && topRecords.length > 0 + ? [ + { + title: i18n.translate('xpack.ml.previewAlert.topRecordsLabel', { + defaultMessage: 'Top records:', + }), + description: ( +
    + {topRecords.map((i) => ( +
  • + + {i.function}({i.field_name}) + {' '} + {i.by_field_value} {i.over_field_value} {i.partition_field_value} [{i.score}] +
  • + ))} +
+ ), + }, + ] + : []), + ]; + + return ; + } +); + +export const PreviewAlertCondition: FC = ({ + alertingApiService, + alertParams, +}) => { + const sampleSize = ALERT_PREVIEW_SAMPLE_SIZE; + + const [lookBehindInterval, setLookBehindInterval] = useState(); + const [areResultsVisible, setAreResultVisible] = useState(true); + const [previewError, setPreviewError] = useState(); + const [previewResponse, setPreviewResponse] = useState(); + + const validators = useMemo( + () => composeValidators(requiredValidator(), timeIntervalInputValidator()), + [] + ); + + const validationErrors = useMemo(() => validators(lookBehindInterval), [lookBehindInterval]); + + useEffect( + function resetPreview() { + setPreviewResponse(undefined); + }, + [alertParams] + ); + + const testCondition = useCallback(async () => { + try { + const response = await alertingApiService.preview({ + alertParams, + timeRange: lookBehindInterval!, + sampleSize, + }); + setPreviewResponse(response); + setPreviewError(undefined); + } catch (e) { + setPreviewResponse(undefined); + setPreviewError(e.body ?? e); + } + }, [alertParams, lookBehindInterval]); + + const sampleHits = useMemo(() => { + if (!previewResponse) return; + + return previewResponse.results; + }, [previewResponse]); + + const isReady = + (alertParams.jobSelection?.jobIds?.length! > 0 || + alertParams.jobSelection?.groupIds?.length! > 0) && + !!alertParams.resultType && + !!alertParams.severity && + validationErrors === null; + + const isInvalid = lookBehindInterval !== undefined && !!validationErrors; + + return ( + <> + + + + } + isInvalid={isInvalid} + error={invalidTimeIntervalMessage(lookBehindInterval)} + > + { + setLookBehindInterval(e.target.value); + }} + isInvalid={isInvalid} + data-test-subj={'mlAnomalyAlertPreviewInterval'} + /> + + + + + + + + + + {previewError !== undefined && ( + <> + + + } + color="danger" + iconType="alert" + > +

{previewError.message}

+
+ + )} + + {previewResponse && sampleHits && ( + <> + + + + + + + + + + {sampleHits.length > 0 && ( + + + {areResultsVisible ? ( + + ) : ( + + )} + + + )} + + + {areResultsVisible && sampleHits.length > 0 ? ( + +
    + {sampleHits.map((v, i) => { + return ( +
  • + + {i !== sampleHits.length - 1 ? : null} +
  • + ); + })} +
+ {previewResponse.count > sampleSize ? ( + <> + + + + + + + + ) : null} +
+ ) : null} + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts new file mode 100644 index 00000000000000..7f55eba9cbdc20 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { lazy } from 'react'; +import { MlStartDependencies } from '../plugin'; +import { ML_ALERT_TYPES } from '../../common/constants/alerts'; +import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; + +export function registerMlAlerts( + alertTypeRegistry: MlStartDependencies['triggersActionsUi']['alertTypeRegistry'] +) { + alertTypeRegistry.register({ + id: ML_ALERT_TYPES.ANOMALY_DETECTION, + description: i18n.translate('xpack.ml.alertTypes.anomalyDetection.description', { + defaultMessage: 'Alert when anomaly detection jobs results match the condition.', + }), + iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/machine-learning/${docLinks.DOC_LINK_VERSION}/ml-configuring-alerts.html`; + }, + alertParamsExpression: lazy(() => import('./ml_anomaly_alert_trigger')), + validate: (alertParams: MlAnomalyDetectionAlertParams) => { + const validationResult = { + errors: { + jobSelection: new Array(), + severity: new Array(), + resultType: new Array(), + }, + }; + + if ( + !alertParams.jobSelection?.jobIds?.length && + !alertParams.jobSelection?.groupIds?.length + ) { + validationResult.errors.jobSelection.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { + defaultMessage: 'Job selection is required', + }) + ); + } + + if (alertParams.severity === undefined) { + validationResult.errors.severity.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.severity.errorMessage', { + defaultMessage: 'Anomaly severity is required', + }) + ); + } + + if (alertParams.resultType === undefined) { + validationResult.errors.resultType.push( + i18n.translate('xpack.ml.alertTypes.anomalyDetection.resultType.errorMessage', { + defaultMessage: 'Result type is required', + }) + ); + } + + return validationResult; + }, + requiresAppContext: false, + defaultActionMessage: i18n.translate( + 'xpack.ml.alertTypes.anomalyDetection.defaultActionMessage', + { + defaultMessage: `Elastic Stack Machine Learning Alert: +- Job IDs: \\{\\{#context.jobIds\\}\\}\\{\\{context.jobIds\\}\\} - \\{\\{/context.jobIds\\}\\} +- Time: \\{\\{context.timestampIso8601\\}\\} +- Anomaly score: \\{\\{context.score\\}\\} + +Alerts are raised based on real-time scores. Remember that scores may be adjusted over time as data continues to be analyzed. + +\\{\\{! Section might be not relevant if selected jobs don't contain influencer configuration \\}\\} +Top influencers: +\\{\\{#context.topInfluencers\\}\\} + \\{\\{influencer_field_name\\}\\} = \\{\\{influencer_field_value\\}\\} [\\{\\{score\\}\\}] +\\{\\{/context.topInfluencers\\}\\} + +Top records: +\\{\\{#context.topRecords\\}\\} + \\{\\{function\\}\\}(\\{\\{field_name\\}\\}) \\{\\{by_field_value\\}\\} \\{\\{over_field_value\\}\\} \\{\\{partition_field_value\\}\\} [\\{\\{score\\}\\}] +\\{\\{/context.topRecords\\}\\} + +\\{\\{! Replace kibanaBaseUrl if not configured in Kibana \\}\\} +[Open in Anomaly Explorer](\\{\\{\\{context.kibanaBaseUrl\\}\\}\\}\\{\\{\\{context.anomalyExplorerUrl\\}\\}\\}) +`, + } + ), + }); +} diff --git a/x-pack/plugins/ml/public/alerting/result_type_selector.tsx b/x-pack/plugins/ml/public/alerting/result_type_selector.tsx new file mode 100644 index 00000000000000..3f5b29a673da25 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/result_type_selector.tsx @@ -0,0 +1,97 @@ +/* + * 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 { EuiCard, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies'; +import { AnomalyResultType } from '../../common/types/anomalies'; + +export interface ResultTypeSelectorProps { + value: AnomalyResultType | undefined; + onChange: (value: AnomalyResultType) => void; +} + +export const ResultTypeSelector: FC = ({ + value: selectedResultType = [], + onChange, +}) => { + const resultTypeOptions = [ + { + value: ANOMALY_RESULT_TYPE.BUCKET, + title: , + description: ( + + ), + }, + { + value: ANOMALY_RESULT_TYPE.RECORD, + title: , + description: ( + + ), + }, + { + value: ANOMALY_RESULT_TYPE.INFLUENCER, + title: ( + + ), + description: ( + + ), + }, + ]; + + return ( + + } + > + + {resultTypeOptions.map(({ value, title, description }) => { + return ( + + {description}} + selectable={{ + onClick: () => { + if (selectedResultType === value) { + // don't allow de-select + return; + } + onChange(value); + }, + isSelected: value === selectedResultType, + }} + data-test-subj={`mlAnomalyAlertResult_${value}${ + value === selectedResultType ? '_selected' : '' + }`} + /> + + ); + })} + + + ); +}; diff --git a/x-pack/plugins/ml/public/alerting/severity_control/index.ts b/x-pack/plugins/ml/public/alerting/severity_control/index.ts new file mode 100644 index 00000000000000..a6910c65497642 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/severity_control/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SeverityControl } from './severity_control'; diff --git a/x-pack/plugins/ml/public/alerting/severity_control/severity_control.tsx b/x-pack/plugins/ml/public/alerting/severity_control/severity_control.tsx new file mode 100644 index 00000000000000..26a53882535b67 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/severity_control/severity_control.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiRange, EuiRangeProps } from '@elastic/eui'; +import { SEVERITY_OPTIONS } from '../../application/components/controls/select_severity/select_severity'; +import { ANOMALY_THRESHOLD } from '../../../common'; +import './styles.scss'; + +export interface SeveritySelectorProps { + value: number | undefined; + onChange: (value: number) => void; +} + +const MAX_ANOMALY_SCORE = 100; + +export const SeverityControl: FC = React.memo(({ value, onChange }) => { + const levels: EuiRangeProps['levels'] = [ + { + min: ANOMALY_THRESHOLD.LOW, + max: ANOMALY_THRESHOLD.MINOR - 1, + color: 'success', + }, + { + min: ANOMALY_THRESHOLD.MINOR, + max: ANOMALY_THRESHOLD.MAJOR - 1, + color: 'primary', + }, + { + min: ANOMALY_THRESHOLD.MAJOR, + max: ANOMALY_THRESHOLD.CRITICAL, + color: 'warning', + }, + { + min: ANOMALY_THRESHOLD.CRITICAL, + max: MAX_ANOMALY_SCORE, + color: 'danger', + }, + ]; + + const toggleButtons = SEVERITY_OPTIONS.map((v) => ({ + value: v.val, + label: v.display, + })); + + return ( + + } + > + { + // @ts-ignore Property 'value' does not exist on type 'EventTarget' | (EventTarget & HTMLInputElement) + onChange(e.target.value); + }} + showLabels + showValue + aria-label={i18n.translate('xpack.ml.severitySelector.formControlLabel', { + defaultMessage: 'Select severity threshold', + })} + showTicks + ticks={toggleButtons} + levels={levels} + data-test-subj={'mlAnomalyAlertScoreSelection'} + /> + + ); +}); diff --git a/x-pack/plugins/ml/public/alerting/severity_control/styles.scss b/x-pack/plugins/ml/public/alerting/severity_control/styles.scss new file mode 100644 index 00000000000000..9a5fa8f2b160a9 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/severity_control/styles.scss @@ -0,0 +1,18 @@ +// Color overrides are required (https://github.com/elastic/eui/issues/4467) + +.mlSeverityControl { + .euiRangeLevel-- { + &success { + background-color: #8BC8FB; + } + &primary { + background-color: #FDEC25; + } + &warning { + background-color: #FBA740; + } + &danger { + background-color: #FE5050; + } + } +} diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index 2f938a9aad1d4e..22076c82151545 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -16,6 +16,7 @@ import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; import { usePageUrlState } from '../../../util/url_state'; +import { ANOMALY_THRESHOLD } from '../../../../../common'; const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { defaultMessage: 'warning', @@ -31,10 +32,10 @@ const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalL }); const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, + [warningLabel]: ANOMALY_THRESHOLD.LOW, + [minorLabel]: ANOMALY_THRESHOLD.MINOR, + [majorLabel]: ANOMALY_THRESHOLD.MAJOR, + [criticalLabel]: ANOMALY_THRESHOLD.CRITICAL, }; interface TableSeverity { @@ -45,24 +46,24 @@ interface TableSeverity { export const SEVERITY_OPTIONS: TableSeverity[] = [ { - val: 0, + val: ANOMALY_THRESHOLD.LOW, display: warningLabel, - color: getSeverityColor(0), + color: getSeverityColor(ANOMALY_THRESHOLD.LOW), }, { - val: 25, + val: ANOMALY_THRESHOLD.MINOR, display: minorLabel, - color: getSeverityColor(25), + color: getSeverityColor(ANOMALY_THRESHOLD.MINOR), }, { - val: 50, + val: ANOMALY_THRESHOLD.MAJOR, display: majorLabel, - color: getSeverityColor(50), + color: getSeverityColor(ANOMALY_THRESHOLD.MAJOR), }, { - val: 75, + val: ANOMALY_THRESHOLD.CRITICAL, display: criticalLabel, - color: getSeverityColor(75), + color: getSeverityColor(ANOMALY_THRESHOLD.CRITICAL), }, ]; @@ -84,7 +85,7 @@ export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); }; -const getSeverityOptions = () => +export const getSeverityOptions = () => SEVERITY_OPTIONS.map(({ color, display, val }) => ({ value: display, inputDisplay: ( diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index 7fd79dc4234a12..3c29af69a05356 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -27,8 +27,8 @@ export const useSelectedCells = ( let times = appState.mlExplorerSwimlane.selectedTimes ?? appState.mlExplorerSwimlane.selectedTime!; - if (typeof times === 'number' && bucketIntervalInSeconds) { - times = [times, times + bucketIntervalInSeconds]; + if (typeof times === 'number') { + times = [times, times + bucketIntervalInSeconds!]; } let lanes = diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts index 8e565e09cde0e4..353ce317fbd42f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts @@ -203,7 +203,7 @@ export function populateValidationMessages( } } -function invalidTimeIntervalMessage(value: string | undefined) { +export function invalidTimeIntervalMessage(value: string | undefined) { return i18n.translate( 'xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage', { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/alerting.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/alerting.ts new file mode 100644 index 00000000000000..ddf32db80c03a0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/alerting.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 { HttpService } from '../http_service'; +import { ML_BASE_PATH } from '../../../../common/constants/app'; +import { MlAnomalyDetectionAlertParams, PreviewResponse } from '../../../../common/types/alerts'; + +export type AlertingApiService = ReturnType; + +export const alertingApiProvider = (httpService: HttpService) => { + return { + preview(params: { + alertParams: MlAnomalyDetectionAlertParams; + timeRange: string; + sampleSize?: number; + }): Promise { + const body = JSON.stringify(params); + return httpService.http({ + path: `${ML_BASE_PATH}/alerting/preview`, + method: 'POST', + body, + }); + }, + }; +}; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 6ecce937056e1d..400841587bf8c6 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -8,32 +8,32 @@ import { Observable } from 'rxjs'; import { HttpService } from '../http_service'; -import { basePath } from './index'; -import { Dictionary } from '../../../../common/types/common'; -import { +import type { Dictionary } from '../../../../common/types/common'; +import type { MlJobWithTimeRange, MlSummaryJobs, CombinedJobWithStats, Job, Datafeed, } from '../../../../common/types/anomaly_detection_jobs'; -import { JobMessage } from '../../../../common/types/audit_message'; -import { AggFieldNamePair } from '../../../../common/types/fields'; -import { ExistingJobsAndGroups } from '../job_service'; -import { +import type { JobMessage } from '../../../../common/types/audit_message'; +import type { AggFieldNamePair } from '../../../../common/types/fields'; +import type { ExistingJobsAndGroups } from '../job_service'; +import type { CategorizationAnalyzer, CategoryFieldExample, FieldExampleCheck, } from '../../../../common/types/categories'; import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; -import { Category } from '../../../../common/types/categories'; -import { JobsExistResponse } from '../../../../common/types/job_service'; +import type { Category } from '../../../../common/types/categories'; +import type { JobsExistResponse } from '../../../../common/types/job_service'; +import { ML_BASE_PATH } from '../../../../common/constants/app'; export const jobsApiProvider = (httpService: HttpService) => ({ jobsSummary(jobIds: string[]) { const body = JSON.stringify({ jobIds }); return httpService.http({ - path: `${basePath()}/jobs/jobs_summary`, + path: `${ML_BASE_PATH}/jobs/jobs_summary`, method: 'POST', body, }); @@ -45,7 +45,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary; }>({ - path: `${basePath()}/jobs/jobs_with_time_range`, + path: `${ML_BASE_PATH}/jobs/jobs_with_time_range`, method: 'POST', body, }); @@ -54,7 +54,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ jobForCloning(jobId: string) { const body = JSON.stringify({ jobId }); return httpService.http<{ job?: Job; datafeed?: Datafeed } | undefined>({ - path: `${basePath()}/jobs/job_for_cloning`, + path: `${ML_BASE_PATH}/jobs/job_for_cloning`, method: 'POST', body, }); @@ -63,7 +63,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ jobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); return httpService.http({ - path: `${basePath()}/jobs/jobs`, + path: `${ML_BASE_PATH}/jobs/jobs`, method: 'POST', body, }); @@ -71,7 +71,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ groups() { return httpService.http({ - path: `${basePath()}/jobs/groups`, + path: `${ML_BASE_PATH}/jobs/groups`, method: 'GET', }); }, @@ -79,7 +79,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ updateGroups(updatedJobs: string[]) { const body = JSON.stringify({ jobs: updatedJobs }); return httpService.http({ - path: `${basePath()}/jobs/update_groups`, + path: `${ML_BASE_PATH}/jobs/update_groups`, method: 'POST', body, }); @@ -93,7 +93,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); return httpService.http({ - path: `${basePath()}/jobs/force_start_datafeeds`, + path: `${ML_BASE_PATH}/jobs/force_start_datafeeds`, method: 'POST', body, }); @@ -102,7 +102,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ stopDatafeeds(datafeedIds: string[]) { const body = JSON.stringify({ datafeedIds }); return httpService.http({ - path: `${basePath()}/jobs/stop_datafeeds`, + path: `${ML_BASE_PATH}/jobs/stop_datafeeds`, method: 'POST', body, }); @@ -111,7 +111,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ deleteJobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); return httpService.http({ - path: `${basePath()}/jobs/delete_jobs`, + path: `${ML_BASE_PATH}/jobs/delete_jobs`, method: 'POST', body, }); @@ -120,7 +120,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ closeJobs(jobIds: string[]) { const body = JSON.stringify({ jobIds }); return httpService.http({ - path: `${basePath()}/jobs/close_jobs`, + path: `${ML_BASE_PATH}/jobs/close_jobs`, method: 'POST', body, }); @@ -129,7 +129,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ forceStopAndCloseJob(jobId: string) { const body = JSON.stringify({ jobId }); return httpService.http<{ success: boolean }>({ - path: `${basePath()}/jobs/force_stop_and_close_job`, + path: `${ML_BASE_PATH}/jobs/force_stop_and_close_job`, method: 'POST', body, }); @@ -139,7 +139,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ const jobIdString = jobId !== undefined ? `/${jobId}` : ''; const query = from !== undefined ? { from } : {}; return httpService.http({ - path: `${basePath()}/job_audit_messages/messages${jobIdString}`, + path: `${ML_BASE_PATH}/job_audit_messages/messages${jobIdString}`, method: 'GET', query, }); @@ -147,7 +147,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ deletingJobTasks() { return httpService.http({ - path: `${basePath()}/jobs/deleting_jobs_tasks`, + path: `${ML_BASE_PATH}/jobs/deleting_jobs_tasks`, method: 'GET', }); }, @@ -155,7 +155,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ jobsExist(jobIds: string[], allSpaces: boolean = false) { const body = JSON.stringify({ jobIds, allSpaces }); return httpService.http({ - path: `${basePath()}/jobs/jobs_exist`, + path: `${ML_BASE_PATH}/jobs/jobs_exist`, method: 'POST', body, }); @@ -164,7 +164,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ jobsExist$(jobIds: string[], allSpaces: boolean = false): Observable { const body = JSON.stringify({ jobIds, allSpaces }); return httpService.http$({ - path: `${basePath()}/jobs/jobs_exist`, + path: `${ML_BASE_PATH}/jobs/jobs_exist`, method: 'POST', body, }); @@ -173,7 +173,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ newJobCaps(indexPatternTitle: string, isRollup: boolean = false) { const query = isRollup === true ? { rollup: true } : {}; return httpService.http({ - path: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}`, + path: `${ML_BASE_PATH}/jobs/new_job_caps/${indexPatternTitle}`, method: 'GET', query, }); @@ -202,7 +202,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ splitFieldValue, }); return httpService.http({ - path: `${basePath()}/jobs/new_job_line_chart`, + path: `${ML_BASE_PATH}/jobs/new_job_line_chart`, method: 'POST', body, }); @@ -229,7 +229,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ splitFieldName, }); return httpService.http({ - path: `${basePath()}/jobs/new_job_population_chart`, + path: `${ML_BASE_PATH}/jobs/new_job_population_chart`, method: 'POST', body, }); @@ -237,7 +237,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ getAllJobAndGroupIds() { return httpService.http({ - path: `${basePath()}/jobs/all_jobs_and_group_ids`, + path: `${ML_BASE_PATH}/jobs/all_jobs_and_group_ids`, method: 'GET', }); }, @@ -249,7 +249,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ end, }); return httpService.http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({ - path: `${basePath()}/jobs/look_back_progress`, + path: `${ML_BASE_PATH}/jobs/look_back_progress`, method: 'POST', body, }); @@ -281,7 +281,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ overallValidStatus: CATEGORY_EXAMPLES_VALIDATION_STATUS; validationChecks: FieldExampleCheck[]; }>({ - path: `${basePath()}/jobs/categorization_field_examples`, + path: `${ML_BASE_PATH}/jobs/categorization_field_examples`, method: 'POST', body, }); @@ -293,7 +293,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ total: number; categories: Array<{ count?: number; category: Category }>; }>({ - path: `${basePath()}/jobs/top_categories`, + path: `${ML_BASE_PATH}/jobs/top_categories`, method: 'POST', body, }); @@ -311,7 +311,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ total: number; categories: Array<{ count?: number; category: Category }>; }>({ - path: `${basePath()}/jobs/revert_model_snapshot`, + path: `${ML_BASE_PATH}/jobs/revert_model_snapshot`, method: 'POST', body, }); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 9fd245a7e16ba5..b4eb5a6d702b74 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -47,6 +47,11 @@ import { registerFeature } from './register_feature'; import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; import type { MapsStartApi } from '../../maps/public'; import { LensPublicStart } from '../../lens/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../triggers_actions_ui/public'; +import { registerMlAlerts } from './alerting/register_ml_alerts'; export interface MlStartDependencies { data: DataPublicPluginStart; @@ -57,7 +62,9 @@ export interface MlStartDependencies { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } + export interface MlSetupDependencies { security?: SecurityPluginSetup; licensing: LicensingPluginSetup; @@ -69,6 +76,7 @@ export interface MlSetupDependencies { kibanaVersion: string; share: SharePluginSetup; indexPatternManagement: IndexPatternManagementSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; } export type MlCoreSetup = CoreSetup; @@ -110,6 +118,7 @@ export class MlPlugin implements Plugin { uiActions: pluginsStart.uiActions, lens: pluginsStart.lens, kibanaVersion, + triggersActionsUi: pluginsStart.triggersActionsUi, }, params ); @@ -174,13 +183,14 @@ export class MlPlugin implements Plugin { }; } - start(core: CoreStart, deps: any) { + start(core: CoreStart, deps: MlStartDependencies) { setDependencyCache({ docLinks: core.docLinks!, basePath: core.http.basePath, http: core.http, i18n: core.i18n, }); + registerMlAlerts(deps.triggersActionsUi.alertTypeRegistry); return { urlGenerator: this.urlGenerator, }; diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts new file mode 100644 index 00000000000000..261fac7b620ba9 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolveTimeInterval } from './alerting_service'; + +describe('Alerting Service', () => { + test('should resolve maximum bucket interval', () => { + expect(resolveTimeInterval(['15m', '1h', '6h', '90s'])).toBe('43200s'); + }); +}); diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts new file mode 100644 index 00000000000000..3b83e6d005077b --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -0,0 +1,525 @@ +/* + * 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 rison from 'rison-node'; +import { MlClient } from '../ml_client'; +import { + MlAnomalyDetectionAlertParams, + MlAnomalyDetectionAlertPreviewRequest, +} from '../../routes/schemas/alerting_schema'; +import { ANOMALY_RESULT_TYPE } from '../../../common/constants/anomalies'; +import { AnomalyResultType } from '../../../common/types/anomalies'; +import { + AlertExecutionResult, + InfluencerAnomalyAlertDoc, + PreviewResponse, + PreviewResultsKeys, + RecordAnomalyAlertDoc, + TopHitsResultsKeys, +} from '../../../common/types/alerts'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { AnomalyDetectionAlertContext } from './register_anomaly_detection_alert_type'; +import { MlJobsResponse } from '../../../common/types/job_service'; + +function isDefined(argument: T | undefined | null): argument is T { + return argument !== undefined && argument !== null; +} + +/** + * Resolves the longest bucket span from the list and multiply it by 2. + * @param bucketSpans Collection of bucket spans + */ +export function resolveTimeInterval(bucketSpans: string[]): string { + return `${ + Math.max( + ...bucketSpans + .map((b) => parseInterval(b)) + .filter(isDefined) + .map((v) => v.asSeconds()) + ) * 2 + }s`; +} + +/** + * Alerting related server-side methods + * @param mlClient + */ +export function alertingServiceProvider(mlClient: MlClient) { + const getAggResultsLabel = (resultType: AnomalyResultType) => { + return { + aggGroupLabel: `${resultType}_results` as PreviewResultsKeys, + topHitsLabel: `top_${resultType}_hits` as TopHitsResultsKeys, + }; + }; + + const getCommonScriptedFields = () => { + return { + start: { + script: { + lang: 'painless', + source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()-((doc["bucket_span"].value * 1000) + * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, + params: { + padding: 10, + }, + }, + }, + end: { + script: { + lang: 'painless', + source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()+((doc["bucket_span"].value * 1000) + * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, + params: { + padding: 10, + }, + }, + }, + timestamp_epoch: { + script: { + lang: 'painless', + source: 'doc["timestamp"].value.getMillis()/1000', + }, + }, + timestamp_iso8601: { + script: { + lang: 'painless', + source: 'doc["timestamp"].value', + }, + }, + }; + }; + + /** + * Builds an agg query based on the requested result type. + * @param resultType + * @param severity + */ + const getResultTypeAggRequest = (resultType: AnomalyResultType, severity: number) => { + return { + influencer_results: { + filter: { + range: { + influencer_score: { + gte: resultType === ANOMALY_RESULT_TYPE.INFLUENCER ? severity : 0, + }, + }, + }, + aggs: { + top_influencer_hits: { + top_hits: { + sort: [ + { + influencer_score: { + order: 'desc', + }, + }, + ], + _source: { + includes: [ + 'result_type', + 'timestamp', + 'influencer_field_name', + 'influencer_field_value', + 'influencer_score', + 'is_interim', + 'job_id', + ], + }, + size: 3, + script_fields: { + ...getCommonScriptedFields(), + score: { + script: { + lang: 'painless', + source: 'Math.floor(doc["influencer_score"].value)', + }, + }, + unique_key: { + script: { + lang: 'painless', + source: + 'doc["timestamp"].value + "_" + doc["influencer_field_name"].value + "_" + doc["influencer_field_value"].value', + }, + }, + }, + }, + }, + }, + }, + record_results: { + filter: { + range: { + record_score: { + gte: resultType === ANOMALY_RESULT_TYPE.RECORD ? severity : 0, + }, + }, + }, + aggs: { + top_record_hits: { + top_hits: { + sort: [ + { + record_score: { + order: 'desc', + }, + }, + ], + _source: { + includes: [ + 'result_type', + 'timestamp', + 'record_score', + 'is_interim', + 'function', + 'field_name', + 'by_field_value', + 'over_field_value', + 'partition_field_value', + 'job_id', + ], + }, + size: 3, + script_fields: { + ...getCommonScriptedFields(), + score: { + script: { + lang: 'painless', + source: 'Math.floor(doc["record_score"].value)', + }, + }, + unique_key: { + script: { + lang: 'painless', + source: 'doc["timestamp"].value + "_" + doc["function"].value', + }, + }, + }, + }, + }, + }, + }, + ...(resultType === ANOMALY_RESULT_TYPE.BUCKET + ? { + bucket_results: { + filter: { + range: { + anomaly_score: { + gt: severity, + }, + }, + }, + aggs: { + top_bucket_hits: { + top_hits: { + sort: [ + { + anomaly_score: { + order: 'desc', + }, + }, + ], + _source: { + includes: [ + 'job_id', + 'result_type', + 'timestamp', + 'anomaly_score', + 'is_interim', + ], + }, + size: 1, + script_fields: { + ...getCommonScriptedFields(), + score: { + script: { + lang: 'painless', + source: 'Math.floor(doc["anomaly_score"].value)', + }, + }, + unique_key: { + script: { + lang: 'painless', + source: 'doc["timestamp"].value', + }, + }, + }, + }, + }, + }, + }, + } + : {}), + }; + }; + + /** + * Builds a request body + * @param params + * @param previewTimeInterval + */ + const fetchAnomalies = async ( + params: MlAnomalyDetectionAlertParams, + previewTimeInterval?: string + ): Promise => { + const jobAndGroupIds = [ + ...(params.jobSelection.jobIds ?? []), + ...(params.jobSelection.groupIds ?? []), + ]; + + // Extract jobs from group ids and make sure provided jobs assigned to a current space + const jobsResponse = ( + await mlClient.getJobs({ job_id: jobAndGroupIds.join(',') }) + ).body.jobs; + + if (jobsResponse.length === 0) { + // Probably assigned groups don't contain any jobs anymore. + return; + } + + const lookBackTimeInterval = resolveTimeInterval( + jobsResponse.map((v) => v.analysis_config.bucket_span) + ); + + const jobIds = jobsResponse.map((v) => v.job_id); + + const requestBody = { + size: 0, + query: { + bool: { + filter: [ + { + terms: { job_id: jobIds }, + }, + { + range: { + timestamp: { + gte: `now-${previewTimeInterval ?? lookBackTimeInterval}`, + // Restricts data points to the current moment for preview + ...(previewTimeInterval ? { lte: 'now' } : {}), + }, + }, + }, + { + terms: { + result_type: Object.values(ANOMALY_RESULT_TYPE), + }, + }, + ], + }, + }, + aggs: { + alerts_over_time: { + date_histogram: { + field: 'timestamp', + fixed_interval: lookBackTimeInterval, + // Ignore empty buckets + min_doc_count: 1, + }, + aggs: getResultTypeAggRequest(params.resultType as AnomalyResultType, params.severity), + }, + }, + }; + + const response = await mlClient.anomalySearch( + { + body: requestBody, + }, + jobIds + ); + + const result = response.body.aggregations as { + alerts_over_time: { + buckets: Array< + { + doc_count: number; + key: number; + key_as_string: string; + } & { + [key in PreviewResultsKeys]: { + doc_count: number; + } & { + [hitsKey in TopHitsResultsKeys]: { + hits: { hits: any[] }; + }; + }; + } + >; + }; + }; + + const resultsLabel = getAggResultsLabel(params.resultType as AnomalyResultType); + + return ( + result.alerts_over_time.buckets + // Filter out empty buckets + .filter((v) => v.doc_count > 0 && v[resultsLabel.aggGroupLabel].doc_count > 0) + // Map response + .map((v) => { + const aggTypeResults = v[resultsLabel.aggGroupLabel]; + const requestedAnomalies = aggTypeResults[resultsLabel.topHitsLabel].hits.hits; + + return { + count: aggTypeResults.doc_count, + key: v.key, + key_as_string: v.key_as_string, + jobIds: [...new Set(requestedAnomalies.map((h) => h._source.job_id))], + isInterim: requestedAnomalies.some((h) => h._source.is_interim), + timestamp: requestedAnomalies[0]._source.timestamp, + timestampIso8601: requestedAnomalies[0].fields.timestamp_iso8601[0], + timestampEpoch: requestedAnomalies[0].fields.timestamp_epoch[0], + score: requestedAnomalies[0].fields.score[0], + bucketRange: { + start: requestedAnomalies[0].fields.start[0], + end: requestedAnomalies[0].fields.end[0], + }, + topRecords: v.record_results.top_record_hits.hits.hits.map((h) => ({ + ...h._source, + score: h.fields.score[0], + unique_key: h.fields.unique_key[0], + })) as RecordAnomalyAlertDoc[], + topInfluencers: v.influencer_results.top_influencer_hits.hits.hits.map((h) => ({ + ...h._source, + score: h.fields.score[0], + unique_key: h.fields.unique_key[0], + })) as InfluencerAnomalyAlertDoc[], + }; + }) + ); + }; + + /** + * TODO Replace with URL generator when https://github.com/elastic/kibana/issues/59453 is resolved + * @param r + * @param type + */ + const buildExplorerUrl = (r: AlertExecutionResult, type: AnomalyResultType): string => { + const isInfluencerResult = type === ANOMALY_RESULT_TYPE.INFLUENCER; + + /** + * Disabled until Anomaly Explorer page is fixed and properly + * support single point time selection + */ + const highlightSwimLaneSelection = false; + + const globalState = { + ml: { + jobIds: r.jobIds, + }, + time: { + from: r.bucketRange.start, + to: r.bucketRange.end, + mode: 'absolute', + }, + }; + + const appState = { + explorer: { + mlExplorerFilter: { + ...(isInfluencerResult + ? { + filterActive: true, + filteredFields: [ + r.topInfluencers![0].influencer_field_name, + r.topInfluencers![0].influencer_field_value, + ], + influencersFilterQuery: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + [r.topInfluencers![0].influencer_field_name]: r.topInfluencers![0] + .influencer_field_value, + }, + }, + ], + }, + }, + queryString: `${r.topInfluencers![0].influencer_field_name}:"${ + r.topInfluencers![0].influencer_field_value + }"`, + } + : {}), + }, + mlExplorerSwimlane: { + ...(highlightSwimLaneSelection + ? { + selectedLanes: [ + isInfluencerResult ? r.topInfluencers![0].influencer_field_value : 'Overall', + ], + selectedTimes: r.timestampEpoch, + selectedType: isInfluencerResult ? 'viewBy' : 'overall', + ...(isInfluencerResult + ? { viewByFieldName: r.topInfluencers![0].influencer_field_name } + : {}), + ...(isInfluencerResult ? {} : { showTopFieldValues: true }), + } + : {}), + }, + }, + }; + return `/app/ml/explorer/?_g=${encodeURIComponent( + rison.encode(globalState) + )}&_a=${encodeURIComponent(rison.encode(appState))}`; + }; + + return { + /** + * Return the result of an alert condition execution. + * + * @param params + */ + execute: async ( + params: MlAnomalyDetectionAlertParams, + publicBaseUrl: string | undefined + ): Promise => { + const res = await fetchAnomalies(params); + + if (!res) { + throw new Error('No results found'); + } + + const result = res[0]; + if (!result) return; + + const anomalyExplorerUrl = buildExplorerUrl(result, params.resultType as AnomalyResultType); + + return { + ...result, + name: result.key_as_string, + anomalyExplorerUrl, + kibanaBaseUrl: publicBaseUrl!, + }; + }, + /** + * Checks how often the alert condition will fire an alert instance + * based on the provided relative time window. + * + * @param previewParams + */ + preview: async ({ + alertParams, + timeRange, + sampleSize, + }: MlAnomalyDetectionAlertPreviewRequest): Promise => { + const res = await fetchAnomalies(alertParams, timeRange); + + if (!res) { + throw Boom.notFound(`No results found`); + } + + return { + // sum of all alert responses within the time range + count: res.length, + results: res.slice(0, sampleSize), + }; + }, + }; +} + +export type MlAlertingService = ReturnType; diff --git a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts new file mode 100644 index 00000000000000..6f8fa59aa231e7 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { KibanaRequest } from 'kibana/server'; +import { + ML_ALERT_TYPES, + ML_ALERT_TYPES_CONFIG, + AnomalyScoreMatchGroupId, +} from '../../../common/constants/alerts'; +import { PLUGIN_ID } from '../../../common/constants/app'; +import { MINIMUM_FULL_LICENSE } from '../../../common/license'; +import { + MlAnomalyDetectionAlertParams, + mlAnomalyDetectionAlertParams, +} from '../../routes/schemas/alerting_schema'; +import { RegisterAlertParams } from './register_ml_alerts'; +import { InfluencerAnomalyAlertDoc, RecordAnomalyAlertDoc } from '../../../common/types/alerts'; +import { + AlertInstanceContext, + AlertInstanceState, + AlertTypeState, +} from '../../../../alerts/common'; + +const alertTypeConfig = ML_ALERT_TYPES_CONFIG[ML_ALERT_TYPES.ANOMALY_DETECTION]; + +export type AnomalyDetectionAlertContext = { + name: string; + jobIds: string[]; + timestampIso8601: string; + timestamp: number; + score: number; + isInterim: boolean; + topRecords: RecordAnomalyAlertDoc[]; + topInfluencers?: InfluencerAnomalyAlertDoc[]; + anomalyExplorerUrl: string; + kibanaBaseUrl: string; +} & AlertInstanceContext; + +export function registerAnomalyDetectionAlertType({ + alerts, + mlSharedServices, + publicBaseUrl, +}: RegisterAlertParams) { + alerts.registerType< + MlAnomalyDetectionAlertParams, + AlertTypeState, + AlertInstanceState, + AnomalyDetectionAlertContext, + AnomalyScoreMatchGroupId + >({ + id: ML_ALERT_TYPES.ANOMALY_DETECTION, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: mlAnomalyDetectionAlertParams, + }, + actionVariables: { + context: [ + { + name: 'timestamp', + description: i18n.translate('xpack.ml.alertContext.timestampDescription', { + defaultMessage: 'Timestamp of the anomaly', + }), + }, + { + name: 'timestampIso8601', + description: i18n.translate('xpack.ml.alertContext.timestampIso8601Description', { + defaultMessage: 'Time in ISO8601 format', + }), + }, + { + name: 'jobIds', + description: i18n.translate('xpack.ml.alertContext.jobIdsDescription', { + defaultMessage: 'List of job IDs triggered the alert instance', + }), + }, + { + name: 'isInterim', + description: i18n.translate('xpack.ml.alertContext.isInterimDescription', { + defaultMessage: 'Indicate if top hits contain interim results', + }), + }, + { + name: 'score', + description: i18n.translate('xpack.ml.alertContext.scoreDescription', { + defaultMessage: 'Anomaly score', + }), + }, + { + name: 'topRecords', + description: i18n.translate('xpack.ml.alertContext.topRecordsDescription', { + defaultMessage: 'Top records', + }), + }, + { + name: 'topInfluencers', + description: i18n.translate('xpack.ml.alertContext.topInfluencersDescription', { + defaultMessage: 'Top influencers', + }), + }, + { + name: 'anomalyExplorerUrl', + description: i18n.translate('xpack.ml.alertContext.anomalyExplorerUrlDescription', { + defaultMessage: 'URL to open in the Anomaly Explorer', + }), + useWithTripleBracesInTemplates: true, + }, + // TODO remove when https://github.com/elastic/kibana/pull/90525 is merged + { + name: 'kibanaBaseUrl', + description: i18n.translate('xpack.ml.alertContext.kibanaBasePathUrlDescription', { + defaultMessage: 'Kibana base path', + }), + useWithTripleBracesInTemplates: true, + }, + ], + }, + producer: PLUGIN_ID, + minimumLicenseRequired: MINIMUM_FULL_LICENSE, + async executor({ services, params }) { + const fakeRequest = {} as KibanaRequest; + const { execute } = mlSharedServices.alertingServiceProvider( + services.savedObjectsClient, + fakeRequest + ); + const executionResult = await execute(params, publicBaseUrl); + + if (executionResult) { + const alertInstanceName = executionResult.name; + const alertInstance = services.alertInstanceFactory(alertInstanceName); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, executionResult); + } + }, + }); +} diff --git a/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts new file mode 100644 index 00000000000000..5c9106d78595fc --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertingPlugin } from '../../../../alerts/server'; +import { registerAnomalyDetectionAlertType } from './register_anomaly_detection_alert_type'; +import { SharedServices } from '../../shared_services'; + +export interface RegisterAlertParams { + alerts: AlertingPlugin['setup']; + mlSharedServices: SharedServices; + publicBaseUrl: string | undefined; +} + +export function registerMlAlerts(params: RegisterAlertParams) { + registerAnomalyDetectionAlertType(params); +} diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 34076e5f2b498d..10ed70d7f73969 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -57,6 +57,9 @@ import { savedObjectClientsFactory, } from './saved_objects'; import { RouteGuard } from './lib/route_guard'; +import { registerMlAlerts } from './lib/alerts/register_ml_alerts'; +import { ML_ALERT_TYPES } from '../common/constants/alerts'; +import { alertingRoutes } from './routes/alerting'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; @@ -98,6 +101,7 @@ export class MlServerPlugin management: { insightsAndAlerting: ['jobsListLink'], }, + alerting: Object.values(ML_ALERT_TYPES), privileges: { all: admin, read: user, @@ -123,6 +127,7 @@ export class MlServerPlugin ], }, }); + registerKibanaSettings(coreSetup); this.mlLicense.setup(plugins.licensing.license$, [ @@ -188,21 +193,30 @@ export class MlServerPlugin resolveMlCapabilities, }); trainedModelsRoutes(routeInit); + alertingRoutes(routeInit); initMlServerLog({ log: this.log }); - return { - ...createSharedServices( - this.mlLicense, - getSpaces, - plugins.cloud, - plugins.security?.authz, - resolveMlCapabilities, - () => this.clusterClient, - () => getInternalSavedObjectsClient(), - () => this.isMlReady - ), - }; + const sharedServices = createSharedServices( + this.mlLicense, + getSpaces, + plugins.cloud, + plugins.security?.authz, + resolveMlCapabilities, + () => this.clusterClient, + () => getInternalSavedObjectsClient(), + () => this.isMlReady + ); + + if (plugins.alerts) { + registerMlAlerts({ + alerts: plugins.alerts, + mlSharedServices: sharedServices, + publicBaseUrl: coreSetup.http.basePath.publicBaseUrl, + }); + } + + return { ...sharedServices }; } public start(coreStart: CoreStart): MlPluginStart { diff --git a/x-pack/plugins/ml/server/routes/alerting.ts b/x-pack/plugins/ml/server/routes/alerting.ts new file mode 100644 index 00000000000000..b7a1be2434e8bc --- /dev/null +++ b/x-pack/plugins/ml/server/routes/alerting.ts @@ -0,0 +1,45 @@ +/* + * 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 { RouteInitialization } from '../types'; +import { wrapError } from '../client/error_wrapper'; +import { alertingServiceProvider } from '../lib/alerts/alerting_service'; +import { mlAnomalyDetectionAlertPreviewRequest } from './schemas/alerting_schema'; + +export function alertingRoutes({ router, routeGuard }: RouteInitialization) { + /** + * @apiGroup Alerting + * + * @api {post} /api/ml/alerting/preview Preview alerting condition + * @apiName PreviewAlert + * @apiDescription Returns a preview of the alerting condition + */ + router.post( + { + path: '/api/ml/alerting/preview', + validate: { + body: mlAnomalyDetectionAlertPreviewRequest, + }, + options: { + tags: ['access:ml:canGetJobs'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { + try { + const alertingService = alertingServiceProvider(mlClient); + + const result = await alertingService.preview(request.body); + + return response.ok({ + body: result, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); +} diff --git a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts new file mode 100644 index 00000000000000..636185808f9a5b --- /dev/null +++ b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { ALERT_PREVIEW_SAMPLE_SIZE } from '../../../common/constants/alerts'; + +export const mlAnomalyDetectionAlertParams = schema.object({ + jobSelection: schema.object( + { + jobIds: schema.arrayOf(schema.string(), { defaultValue: [] }), + groupIds: schema.arrayOf(schema.string(), { defaultValue: [] }), + }, + { + validate: (v) => { + if (!v.jobIds?.length && !v.groupIds?.length) { + return i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { + defaultMessage: 'Job selection is required', + }); + } + }, + } + ), + severity: schema.number(), + resultType: schema.string(), +}); + +export const mlAnomalyDetectionAlertPreviewRequest = schema.object({ + alertParams: mlAnomalyDetectionAlertParams, + /** + * Relative time range to look back from now, e.g. 1y, 8m, 15d + */ + timeRange: schema.string(), + /** + * Number of top hits to return + */ + sampleSize: schema.number({ defaultValue: ALERT_PREVIEW_SAMPLE_SIZE, min: 0 }), +}); + +export type MlAnomalyDetectionAlertParams = TypeOf; + +export type MlAnomalyDetectionAlertPreviewRequest = TypeOf< + typeof mlAnomalyDetectionAlertPreviewRequest +>; diff --git a/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts b/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts new file mode 100644 index 00000000000000..318dac200a8772 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { GetGuards } from '../shared_services'; +import { alertingServiceProvider, MlAlertingService } from '../../lib/alerts/alerting_service'; + +export function getAlertingServiceProvider(getGuards: GetGuards) { + return { + alertingServiceProvider( + savedObjectsClient: SavedObjectsClientContract, + request: KibanaRequest + ) { + return { + preview: async (...args: Parameters) => { + return await getGuards(request, savedObjectsClient) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(({ mlClient }) => alertingServiceProvider(mlClient).preview(...args)); + }, + execute: async ( + ...args: Parameters + ): ReturnType => { + return await getGuards(request, savedObjectsClient) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(({ mlClient }) => alertingServiceProvider(mlClient).execute(...args)); + }, + }; + }, + }; +} + +export type MlAlertingServiceProvider = ReturnType; diff --git a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts index 89e7b6748015b0..43a7daba4c34de 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts @@ -28,7 +28,7 @@ export function getJobServiceProvider(getGuards: GetGuards): JobServiceProvider return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(async ({ scopedClient, mlClient }) => { + .ok(({ scopedClient, mlClient }) => { const { jobsSummary } = jobServiceProvider(scopedClient, mlClient); return jobsSummary(...args); }); diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 6c17f82823dc50..caed3fd9332983 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -26,12 +26,17 @@ import { hasMlCapabilitiesProvider, HasMlCapabilities } from '../lib/capabilitie import { MLClusterClientUninitialized } from './errors'; import { MlClient, getMlClient } from '../lib/ml_client'; import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; +import { + getAlertingServiceProvider, + MlAlertingServiceProvider, +} from './providers/alerting_service'; export type SharedServices = JobServiceProvider & AnomalyDetectorsProvider & MlSystemProvider & ModulesProvider & - ResultsServiceProvider; + ResultsServiceProvider & + MlAlertingServiceProvider; interface Guards { isMinimumLicense(): Guards; @@ -118,6 +123,7 @@ export function createSharedServices( ...getModulesProvider(getGuards), ...getResultsServiceProvider(getGuards), ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), + ...getAlertingServiceProvider(getGuards), }; } diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index 2a216c686698d0..3927f2cfc72f36 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -15,6 +15,8 @@ import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { MlLicense } from '../common/license'; import type { ResolveMlCapabilities } from '../common/types/capabilities'; import type { RouteGuard } from './lib/route_guard'; +import type { AlertingPlugin } from '../../alerts/server'; +import type { ActionsPlugin } from '../../actions/server'; export interface LicenseCheckResult { isAvailable: boolean; @@ -43,6 +45,8 @@ export interface PluginsSetup { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; spaces?: SpacesPluginSetup; + alerts?: AlertingPlugin['setup']; + actions?: ActionsPlugin['setup']; } export interface PluginsStart { diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index 2caf88de1b76a6..ed520aa80401b7 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -31,5 +31,7 @@ { "path": "../lens/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } ] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index c7278d60ca97ea..02a0582e540f45 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -94,6 +94,7 @@ describe('rules_notification_alert_type', () => { mlSystemProvider: jest.fn(), modulesProvider: jest.fn(), resultsServiceProvider: jest.fn(), + alertingServiceProvider: jest.fn(), }; let payload: jest.Mocked; let alert: ReturnType; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 7358facf215c34..66bab7e41ab54c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -768,6 +768,7 @@ export const AlertForm = ({ setAlertIntervalUnit(e.target.value); setScheduleProperty('interval', `${alertInterval}${e.target.value}`); }} + data-test-subj="intervalInputUnit" /> diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 009648970c1bb5..59f1775bb21177 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.jobId}`; }, dependentVariable: 'y', - trainingPercent: '20', + trainingPercent: 20, modelMemory: '60mb', createIndexPattern: true, expected: { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index c3febd2021da4f..f41944e3409d76 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.jobId}`; }, dependentVariable: 'stab', - trainingPercent: '20', + trainingPercent: 20, modelMemory: '20mb', createIndexPattern: true, expected: { diff --git a/x-pack/test/functional/services/ml/alerting.ts b/x-pack/test/functional/services/ml/alerting.ts new file mode 100644 index 00000000000000..82f6a86d091992 --- /dev/null +++ b/x-pack/test/functional/services/ml/alerting.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommonUI } from './common_ui'; + +export function MachineLearningAlertingProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const testSubjects = getService('testSubjects'); + + return { + async selectAnomalyDetectionAlertType() { + await testSubjects.click('xpack.ml.anomaly_detection_alert-SelectOption'); + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertForm`); + }); + }, + + async selectJobs(jobIds: string[]) { + for (const jobId of jobIds) { + await comboBox.set('mlAnomalyAlertJobSelection > comboBoxInput', jobId); + } + await this.assertJobSelection(jobIds); + }, + + async assertJobSelection(expectedJobIds: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlAnomalyAlertJobSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedJobIds, + `Expected job selection to be '${expectedJobIds}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectResultType(resultType: string) { + await testSubjects.click(`mlAnomalyAlertResult_${resultType}`); + await this.assertResultTypeSelection(resultType); + }, + + async assertResultTypeSelection(resultType: string) { + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertResult_${resultType}_selected`); + }); + }, + + async setSeverity(severity: number) { + await mlCommonUI.setSliderValue('mlAnomalyAlertScoreSelection', severity); + }, + + async assertSeverity(expectedValue: number) { + await mlCommonUI.assertSliderValue('mlAnomalyAlertScoreSelection', expectedValue); + }, + + async setTestInterval(interval: string) { + await testSubjects.setValue('mlAnomalyAlertPreviewInterval', interval); + await this.assertTestIntervalValue(interval); + }, + + async assertTestIntervalValue(expectedInterval: string) { + const actualValue = await testSubjects.getAttribute('mlAnomalyAlertPreviewInterval', 'value'); + expect(actualValue).to.eql( + expectedInterval, + `Expected test interval to equal ${expectedInterval}, got ${actualValue}` + ); + }, + + async assertPreviewButtonState(expectedEnabled: boolean) { + const isEnabled = await testSubjects.isEnabled('mlAnomalyAlertPreviewButton'); + expect(isEnabled).to.eql( + expectedEnabled, + `Expected data frame analytics "create" button to be '${ + expectedEnabled ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }, + + async clickPreviewButton() { + await testSubjects.click('mlAnomalyAlertPreviewButton'); + await this.assertPreviewCalloutVisible(); + }, + + async checkPreview(expectedMessage: string) { + await this.clickPreviewButton(); + const previewMessage = await testSubjects.getVisibleText('mlAnomalyAlertPreviewMessage'); + expect(previewMessage).to.eql(expectedMessage); + }, + + async assertPreviewCalloutVisible() { + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertPreviewCallout`); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/common_ui.ts b/x-pack/test/functional/services/ml/common_ui.ts index bf24a781fabc3e..727f6493910ff1 100644 --- a/x-pack/test/functional/services/ml/common_ui.ts +++ b/x-pack/test/functional/services/ml/common_ui.ts @@ -163,5 +163,54 @@ export function MachineLearningCommonUIProvider({ getService }: FtrProviderConte // escape popover await browser.pressKeys(browser.keys.ESCAPE); }, + + async setSliderValue(testDataSubj: string, value: number) { + const slider = await testSubjects.find(testDataSubj); + + let currentValue = await slider.getAttribute('value'); + let currentDiff = +currentValue - +value; + + await retry.tryForTime(60 * 1000, async () => { + if (currentDiff === 0) { + return true; + } else { + if (currentDiff > 0) { + if (Math.abs(currentDiff) >= 10) { + slider.type(browser.keys.PAGE_DOWN); + } else { + slider.type(browser.keys.ARROW_LEFT); + } + } else { + if (Math.abs(currentDiff) >= 10) { + slider.type(browser.keys.PAGE_UP); + } else { + slider.type(browser.keys.ARROW_RIGHT); + } + } + await retry.tryForTime(1000, async () => { + const newValue = await slider.getAttribute('value'); + if (newValue !== currentValue) { + currentValue = newValue; + currentDiff = +currentValue - +value; + return true; + } else { + throw new Error(`slider value should have changed, but is still ${currentValue}`); + } + }); + + throw new Error(`slider value should be '${value}' (got '${currentValue}')`); + } + }); + + await this.assertSliderValue(testDataSubj, value); + }, + + async assertSliderValue(testDataSubj: string, expectedValue: number) { + const actualValue = await testSubjects.getAttribute(testDataSubj, 'value'); + expect(actualValue).to.eql( + expectedValue, + `${testDataSubj} slider value should be '${expectedValue}' (got '${actualValue}')` + ); + }, }; } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 792241dd9fc163..66c25991274312 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -24,7 +24,6 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); - const browser = getService('browser'); return { async assertJobTypeSelectExists() { @@ -273,45 +272,11 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( ); }, - async setTrainingPercent(trainingPercent: string) { - const slider = await testSubjects.find('mlAnalyticsCreateJobWizardTrainingPercentSlider'); - - let currentValue = await slider.getAttribute('value'); - let currentDiff = +currentValue - +trainingPercent; - - await retry.tryForTime(60 * 1000, async () => { - if (currentDiff === 0) { - return true; - } else { - if (currentDiff > 0) { - if (Math.abs(currentDiff) >= 10) { - slider.type(browser.keys.PAGE_DOWN); - } else { - slider.type(browser.keys.ARROW_LEFT); - } - } else { - if (Math.abs(currentDiff) >= 10) { - slider.type(browser.keys.PAGE_UP); - } else { - slider.type(browser.keys.ARROW_RIGHT); - } - } - await retry.tryForTime(1000, async () => { - const newValue = await slider.getAttribute('value'); - if (newValue !== currentValue) { - currentValue = newValue; - currentDiff = +currentValue - +trainingPercent; - return true; - } else { - throw new Error(`slider value should have changed, but is still ${currentValue}`); - } - }); - - throw new Error(`slider value should be '${trainingPercent}' (got '${currentValue}')`); - } - }); - - await this.assertTrainingPercentValue(trainingPercent); + async setTrainingPercent(trainingPercent: number) { + await mlCommonUI.setSliderValue( + 'mlAnalyticsCreateJobWizardTrainingPercentSlider', + trainingPercent + ); }, async assertConfigurationStepActive() { diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 202dc1e1d2ce84..91d009316cf9e8 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -45,6 +45,7 @@ import { MachineLearningSingleMetricViewerProvider } from './single_metric_viewe import { MachineLearningTestExecutionProvider } from './test_execution'; import { MachineLearningTestResourcesProvider } from './test_resources'; import { MachineLearningDataVisualizerTableProvider } from './data_visualizer_table'; +import { MachineLearningAlertingProvider } from './alerting'; export function MachineLearningProvider(context: FtrProviderContext) { const commonAPI = MachineLearningCommonAPIProvider(context); @@ -95,10 +96,12 @@ export function MachineLearningProvider(context: FtrProviderContext) { const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI); const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); + const alerting = MachineLearningAlertingProvider(context, commonUI); return { anomaliesTable, anomalyExplorer, + alerting, api, commonAPI, commonConfig, diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 3b2d4ef3efa5ae..57ee7e5ad09549 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -36,6 +36,12 @@ export function MachineLearningNavigationProvider({ }); }, + async navigateToAlertsAndAction() { + await PageObjects.common.navigateToApp('triggersActions'); + await testSubjects.click('alertsTab'); + await testSubjects.existOrFail('alertsList'); + }, + async assertTabsExist(tabTypeSubject: string, areaSubjects: string[]) { await retry.tryForTime(10000, async () => { const allTabs = await testSubjects.findAll(`~${tabTypeSubject}`, 3); diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts new file mode 100644 index 00000000000000..c3859e1044b4fa --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATAFEED_STATE } from '../../../../plugins/ml/common/constants/states'; + +function createTestJobAndDatafeed() { + const timestamp = Date.now(); + const jobId = `ec-high_sum_total_sales_${timestamp}`; + + return { + job: { + job_id: jobId, + description: 'test_job_annotation', + groups: ['ecommerce'], + analysis_config: { + bucket_span: '1h', + detectors: [ + { + detector_description: 'High total sales', + function: 'high_sum', + field_name: 'taxful_total_price', + over_field_name: 'customer_full_name.keyword', + detector_index: 0, + }, + ], + influencers: ['customer_full_name.keyword', 'category.keyword'], + }, + data_description: { + time_field: 'order_date', + time_format: 'epoch_ms', + }, + analysis_limits: { + model_memory_limit: '13mb', + categorization_examples_limit: 4, + }, + }, + datafeed: { + datafeed_id: `datafeed-${jobId}`, + job_id: jobId, + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + filter: [], + must_not: [], + }, + }, + indices: ['ft_ecommerce'], + }, + }; +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const pageObjects = getPageObjects(['triggersActionsUI']); + + let testJobId = ''; + + describe('anomaly detection alert', function () { + this.tags('ciGroup13'); + + before(async () => { + await esArchiver.loadIfNeeded('ml/ecommerce'); + await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + + const { job, datafeed } = createTestJobAndDatafeed(); + + testJobId = job.job_id; + + // Set up jobs + await ml.api.createAnomalyDetectionJob(job); + await ml.api.openAnomalyDetectionJob(job.job_id); + await ml.api.createDatafeed(datafeed); + await ml.api.startDatafeed(datafeed.datafeed_id); + await ml.api.waitForDatafeedState(datafeed.datafeed_id, DATAFEED_STATE.STARTED); + await ml.api.assertJobResultsExist(job.job_id); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + describe('overview page alert flyout controls', () => { + it('can create an anomaly detection alert', async () => { + await ml.navigation.navigateToAlertsAndAction(); + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await ml.alerting.selectAnomalyDetectionAlertType(); + + await ml.testExecution.logTestStep('should have correct default values'); + await ml.alerting.assertSeverity(75); + await ml.alerting.assertPreviewButtonState(false); + + await ml.testExecution.logTestStep('should complete the alert params'); + await ml.alerting.selectJobs([testJobId]); + await ml.alerting.selectResultType('record'); + await ml.alerting.setSeverity(10); + + await ml.testExecution.logTestStep('should preview the alert condition'); + await ml.alerting.assertPreviewButtonState(false); + await ml.alerting.setTestInterval('2y'); + await ml.alerting.assertPreviewButtonState(true); + await ml.alerting.checkPreview('Triggers 2 times in the last 2y'); + + await ml.testExecution.logTestStep('should create an alert'); + await pageObjects.triggersActionsUI.setAlertName('ml-test-alert'); + await pageObjects.triggersActionsUI.setAlertInterval(10, 's'); + await pageObjects.triggersActionsUI.saveAlert(); + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList('ml-test-alert'); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/index.ts b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts new file mode 100644 index 00000000000000..3d0a1c0e4cc75c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile, getService }: FtrProviderContext) => { + const ml = getService('ml'); + const esArchiver = getService('esArchiver'); + + describe('ML app', function () { + this.tags(['mlqa', 'skipFirefox']); + + before(async () => { + await ml.securityCommon.createMlRoles(); + await ml.securityCommon.createMlUsers(); + }); + + after(async () => { + await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); + await esArchiver.unload('ml/ecommerce'); + await ml.securityCommon.cleanMlUsers(); + await ml.securityCommon.cleanMlRoles(); + await ml.testResources.resetKibanaTimeZone(); + await ml.securityUI.logout(); + }); + + loadTestFile(require.resolve('./alert_flyout')); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index a7259f2410d6b9..5dd1890e240a4d 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -46,6 +46,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './apps/triggers_actions_ui'), resolve(__dirname, './apps/uptime'), + resolve(__dirname, './apps/ml'), ], apps: { ...xpackFunctionalConfig.get('apps'), diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 8616cb7c904413..7b5e0c81479f92 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -157,5 +157,34 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) ); await createBtn.click(); }, + async setAlertName(value: string) { + await testSubjects.setValue('alertNameInput', value); + await this.assertAlertName(value); + }, + async assertAlertName(expectedValue: string) { + const actualValue = await testSubjects.getAttribute('alertNameInput', 'value'); + expect(actualValue).to.eql(expectedValue); + }, + async setAlertInterval(value: number, unit?: 's' | 'm' | 'h' | 'd') { + await testSubjects.setValue('intervalInput', value.toString()); + if (unit) { + await testSubjects.selectValue('intervalInputUnit', unit); + } + await this.assertAlertInterval(value, unit); + }, + async assertAlertInterval(expectedValue: number, expectedUnit?: 's' | 'm' | 'h' | 'd') { + const actualValue = await testSubjects.getAttribute('intervalInput', 'value'); + expect(actualValue).to.eql(expectedValue); + if (expectedUnit) { + const actualUnitValue = await testSubjects.getAttribute('intervalInputUnit', 'value'); + expect(actualUnitValue).to.eql(expectedUnit); + } + }, + async saveAlert() { + await testSubjects.click('saveAlertButton'); + const isConfirmationModalVisible = await testSubjects.isDisplayed('confirmAlertSaveModal'); + expect(isConfirmationModalVisible).to.eql(true, 'Expect confirmation modal to be visible'); + await testSubjects.click('confirmModalConfirmButton'); + }, }; } From a1490d46f419002f28492d2bcdb26fe5c4a5880c Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 11 Feb 2021 18:34:25 +0100 Subject: [PATCH 09/26] TS config cleanup (#90492) * exclude all the plugins from src/plugins * move all the used fixtures to discover * remove src/fixtures alias * remove unused fixtures * cleanup x-pack/tsconfig.json * dont compile apm/scripts * fix tests * dont include infra in xpack/tsconfig.json * update list of includes --- packages/kbn-test/jest-preset.js | 1 - src/dev/precommit_hook/casing_check_config.js | 1 - src/fixtures/agg_resp/date_histogram.js | 258 -------- src/fixtures/agg_resp/geohash_grid.js | 84 --- src/fixtures/agg_resp/range.js | 45 -- src/fixtures/config_upgrade_from_4.0.0.json | 25 - ..._upgrade_from_4.0.0_to_4.0.1-snapshot.json | 35 - .../config_upgrade_from_4.0.0_to_4.0.1.json | 35 - src/fixtures/fake_chart_events.js | 28 - src/fixtures/fake_hierarchical_data.ts | 621 ------------------ src/fixtures/field_mapping.js | 68 -- src/fixtures/hits.js | 41 -- src/fixtures/mapping_with_dupes.js | 46 -- src/fixtures/mock_index_patterns.js | 19 - src/fixtures/mock_state.js | 20 - src/fixtures/mock_ui_state.js | 33 - src/fixtures/search_response.js | 24 - src/fixtures/stubbed_search_source.js | 54 -- .../discover/public/__fixtures__}/fake_row.js | 0 .../public/__fixtures__}/logstash_fields.js | 3 +- .../public/__fixtures__}/real_hits.js | 0 .../stubbed_logstash_index_pattern.js | 9 +- .../stubbed_saved_object_index_pattern.ts | 2 +- .../doc_table/components/row_headers.test.js | 4 +- .../angular/doc_table/doc_table.test.js | 4 +- .../doc_table/lib/get_default_sort.test.ts | 2 +- .../angular/doc_table/lib/get_sort.test.ts | 2 +- .../lib/get_sort_for_search_source.test.ts | 2 +- .../sidebar/discover_field.test.tsx | 2 +- .../sidebar/discover_field_details.test.tsx | 2 +- .../discover_field_details_footer.test.tsx | 2 +- .../sidebar/discover_sidebar.test.tsx | 4 +- .../discover_sidebar_responsive.test.tsx | 4 +- .../sidebar/lib/field_calculator.test.ts | 4 +- .../public/__fixtures__/logstash_fields.js | 75 +++ .../stubbed_logstash_index_pattern.js | 47 ++ src/plugins/visualizations/public/vis.test.ts | 2 +- src/type_definitions/react_virtualized.d.ts | 11 - tsconfig.base.json | 3 +- tsconfig.json | 60 +- .../fleet/hooks/use_request/use_request.ts | 5 +- .../fleet/mock/fleet_start_services.tsx | 2 +- .../public/applications/fleet/mock/types.ts | 2 +- x-pack/plugins/fleet/server/mocks.ts | 14 + .../server/routes/limited_concurrency.ts | 5 +- .../routes/package_policy/handlers.test.ts | 3 +- .../server/routes/setup/handlers.test.ts | 3 +- .../fleet/server/saved_objects/index.ts | 3 +- .../server/saved_objects/security_solution.js | 11 + .../fleet/server/services/app_context.ts | 7 +- .../server/services/package_policy.test.ts | 3 +- .../fleet/server/services/package_policy.ts | 2 + .../fleet/server/services/setup.test.ts | 3 +- x-pack/plugins/fleet/tsconfig.json | 6 +- x-pack/plugins/osquery/tsconfig.json | 2 +- x-pack/tsconfig.json | 76 +-- 56 files changed, 216 insertions(+), 1613 deletions(-) delete mode 100644 src/fixtures/agg_resp/date_histogram.js delete mode 100644 src/fixtures/agg_resp/geohash_grid.js delete mode 100644 src/fixtures/agg_resp/range.js delete mode 100644 src/fixtures/config_upgrade_from_4.0.0.json delete mode 100644 src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json delete mode 100644 src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json delete mode 100644 src/fixtures/fake_chart_events.js delete mode 100644 src/fixtures/fake_hierarchical_data.ts delete mode 100644 src/fixtures/field_mapping.js delete mode 100644 src/fixtures/hits.js delete mode 100644 src/fixtures/mapping_with_dupes.js delete mode 100644 src/fixtures/mock_index_patterns.js delete mode 100644 src/fixtures/mock_state.js delete mode 100644 src/fixtures/mock_ui_state.js delete mode 100644 src/fixtures/search_response.js delete mode 100644 src/fixtures/stubbed_search_source.js rename src/{fixtures => plugins/discover/public/__fixtures__}/fake_row.js (100%) rename src/{fixtures => plugins/discover/public/__fixtures__}/logstash_fields.js (96%) rename src/{fixtures => plugins/discover/public/__fixtures__}/real_hits.js (100%) rename src/{fixtures => plugins/discover/public/__fixtures__}/stubbed_logstash_index_pattern.js (81%) create mode 100644 src/plugins/visualizations/public/__fixtures__/logstash_fields.js create mode 100644 src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js delete mode 100644 src/type_definitions/react_virtualized.d.ts create mode 100644 x-pack/plugins/fleet/server/saved_objects/security_solution.js diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 79fc3db86e066b..a1475985af8df0 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -37,7 +37,6 @@ module.exports = { '\\.ace\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '\\.editor\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '^(!!)?file-loader!': '/packages/kbn-test/target/jest/mocks/file_mock.js', - '^fixtures/(.*)': '/src/fixtures/$1', '^src/core/(.*)': '/src/core/$1', '^src/plugins/(.*)': '/src/plugins/$1', }, diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 14605208334606..2c9dfbe6fcc10f 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -124,7 +124,6 @@ export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ej * @type {Array} */ export const TEMPORARILY_IGNORED_PATHS = [ - 'src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json', 'src/core/server/core_app/assets/favicons/android-chrome-192x192.png', 'src/core/server/core_app/assets/favicons/android-chrome-256x256.png', 'src/core/server/core_app/assets/favicons/android-chrome-512x512.png', diff --git a/src/fixtures/agg_resp/date_histogram.js b/src/fixtures/agg_resp/date_histogram.js deleted file mode 100644 index 29b34f1ce69d0e..00000000000000 --- a/src/fixtures/agg_resp/date_histogram.js +++ /dev/null @@ -1,258 +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. - */ - -export default { - took: 35, - timed_out: false, - _shards: { - total: 2, - successful: 2, - failed: 0, - }, - hits: { - total: 32899, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: [ - { - key_as_string: '2015-01-30T01:00:00.000Z', - key: 1422579600000, - doc_count: 18, - }, - { - key_as_string: '2015-01-30T02:00:00.000Z', - key: 1422583200000, - doc_count: 68, - }, - { - key_as_string: '2015-01-30T03:00:00.000Z', - key: 1422586800000, - doc_count: 146, - }, - { - key_as_string: '2015-01-30T04:00:00.000Z', - key: 1422590400000, - doc_count: 149, - }, - { - key_as_string: '2015-01-30T05:00:00.000Z', - key: 1422594000000, - doc_count: 363, - }, - { - key_as_string: '2015-01-30T06:00:00.000Z', - key: 1422597600000, - doc_count: 555, - }, - { - key_as_string: '2015-01-30T07:00:00.000Z', - key: 1422601200000, - doc_count: 878, - }, - { - key_as_string: '2015-01-30T08:00:00.000Z', - key: 1422604800000, - doc_count: 1133, - }, - { - key_as_string: '2015-01-30T09:00:00.000Z', - key: 1422608400000, - doc_count: 1438, - }, - { - key_as_string: '2015-01-30T10:00:00.000Z', - key: 1422612000000, - doc_count: 1719, - }, - { - key_as_string: '2015-01-30T11:00:00.000Z', - key: 1422615600000, - doc_count: 1813, - }, - { - key_as_string: '2015-01-30T12:00:00.000Z', - key: 1422619200000, - doc_count: 1790, - }, - { - key_as_string: '2015-01-30T13:00:00.000Z', - key: 1422622800000, - doc_count: 1582, - }, - { - key_as_string: '2015-01-30T14:00:00.000Z', - key: 1422626400000, - doc_count: 1439, - }, - { - key_as_string: '2015-01-30T15:00:00.000Z', - key: 1422630000000, - doc_count: 1154, - }, - { - key_as_string: '2015-01-30T16:00:00.000Z', - key: 1422633600000, - doc_count: 847, - }, - { - key_as_string: '2015-01-30T17:00:00.000Z', - key: 1422637200000, - doc_count: 588, - }, - { - key_as_string: '2015-01-30T18:00:00.000Z', - key: 1422640800000, - doc_count: 374, - }, - { - key_as_string: '2015-01-30T19:00:00.000Z', - key: 1422644400000, - doc_count: 152, - }, - { - key_as_string: '2015-01-30T20:00:00.000Z', - key: 1422648000000, - doc_count: 140, - }, - { - key_as_string: '2015-01-30T21:00:00.000Z', - key: 1422651600000, - doc_count: 73, - }, - { - key_as_string: '2015-01-30T22:00:00.000Z', - key: 1422655200000, - doc_count: 28, - }, - { - key_as_string: '2015-01-30T23:00:00.000Z', - key: 1422658800000, - doc_count: 9, - }, - { - key_as_string: '2015-01-31T00:00:00.000Z', - key: 1422662400000, - doc_count: 29, - }, - { - key_as_string: '2015-01-31T01:00:00.000Z', - key: 1422666000000, - doc_count: 38, - }, - { - key_as_string: '2015-01-31T02:00:00.000Z', - key: 1422669600000, - doc_count: 70, - }, - { - key_as_string: '2015-01-31T03:00:00.000Z', - key: 1422673200000, - doc_count: 136, - }, - { - key_as_string: '2015-01-31T04:00:00.000Z', - key: 1422676800000, - doc_count: 173, - }, - { - key_as_string: '2015-01-31T05:00:00.000Z', - key: 1422680400000, - doc_count: 370, - }, - { - key_as_string: '2015-01-31T06:00:00.000Z', - key: 1422684000000, - doc_count: 545, - }, - { - key_as_string: '2015-01-31T07:00:00.000Z', - key: 1422687600000, - doc_count: 845, - }, - { - key_as_string: '2015-01-31T08:00:00.000Z', - key: 1422691200000, - doc_count: 1070, - }, - { - key_as_string: '2015-01-31T09:00:00.000Z', - key: 1422694800000, - doc_count: 1419, - }, - { - key_as_string: '2015-01-31T10:00:00.000Z', - key: 1422698400000, - doc_count: 1725, - }, - { - key_as_string: '2015-01-31T11:00:00.000Z', - key: 1422702000000, - doc_count: 1801, - }, - { - key_as_string: '2015-01-31T12:00:00.000Z', - key: 1422705600000, - doc_count: 1823, - }, - { - key_as_string: '2015-01-31T13:00:00.000Z', - key: 1422709200000, - doc_count: 1657, - }, - { - key_as_string: '2015-01-31T14:00:00.000Z', - key: 1422712800000, - doc_count: 1454, - }, - { - key_as_string: '2015-01-31T15:00:00.000Z', - key: 1422716400000, - doc_count: 1131, - }, - { - key_as_string: '2015-01-31T16:00:00.000Z', - key: 1422720000000, - doc_count: 810, - }, - { - key_as_string: '2015-01-31T17:00:00.000Z', - key: 1422723600000, - doc_count: 583, - }, - { - key_as_string: '2015-01-31T18:00:00.000Z', - key: 1422727200000, - doc_count: 384, - }, - { - key_as_string: '2015-01-31T19:00:00.000Z', - key: 1422730800000, - doc_count: 165, - }, - { - key_as_string: '2015-01-31T20:00:00.000Z', - key: 1422734400000, - doc_count: 135, - }, - { - key_as_string: '2015-01-31T21:00:00.000Z', - key: 1422738000000, - doc_count: 72, - }, - { - key_as_string: '2015-01-31T22:00:00.000Z', - key: 1422741600000, - doc_count: 8, - }, - ], - }, - }, -}; diff --git a/src/fixtures/agg_resp/geohash_grid.js b/src/fixtures/agg_resp/geohash_grid.js deleted file mode 100644 index 4a8fb3704c9b3b..00000000000000 --- a/src/fixtures/agg_resp/geohash_grid.js +++ /dev/null @@ -1,84 +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 _ from 'lodash'; -export default function GeoHashGridAggResponseFixture() { - // for vis: - // - // vis = new Vis(indexPattern, { - // type: 'tile_map', - // aggs:[ - // { schema: 'metric', type: 'avg', params: { field: 'bytes' } }, - // { schema: 'split', type: 'terms', params: { field: '@tags', size: 10 } }, - // { schema: 'segment', type: 'geohash_grid', params: { field: 'geo.coordinates', precision: 3 } } - // ], - // params: { - // isDesaturated: true, - // mapType: 'Scaled%20Circle%20Markers' - // }, - // }); - - const geoHashCharts = _.union( - _.range(48, 57), // 0-9 - _.range(65, 90), // A-Z - _.range(97, 122) // a-z - ); - - const tags = _.times(_.random(4, 20), function (i) { - // random number of tags - let docCount = 0; - const buckets = _.times(_.random(40, 200), function () { - return _.sampleSize(geoHashCharts, 3).join(''); - }) - .sort() - .map(function (geoHash) { - const count = _.random(1, 5000); - - docCount += count; - - return { - key: geoHash, - doc_count: count, - 1: { - value: 2048 + i, - }, - }; - }); - - return { - key: 'tag ' + (i + 1), - doc_count: docCount, - 3: { - buckets: buckets, - }, - 1: { - value: 1000 + i, - }, - }; - }); - - return { - took: 3, - timed_out: false, - _shards: { - total: 4, - successful: 4, - failed: 0, - }, - hits: { - total: 298, - max_score: 0.0, - hits: [], - }, - aggregations: { - 2: { - buckets: tags, - }, - }, - }; -} diff --git a/src/fixtures/agg_resp/range.js b/src/fixtures/agg_resp/range.js deleted file mode 100644 index ca15f535add82d..00000000000000 --- a/src/fixtures/agg_resp/range.js +++ /dev/null @@ -1,45 +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. - */ - -export default { - took: 35, - timed_out: false, - _shards: { - total: 7, - successful: 7, - failed: 0, - }, - hits: { - total: 218512, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: { - '*-1024.0': { - to: 1024, - to_as_string: '1024.0', - doc_count: 20904, - }, - '1024.0-2560.0': { - from: 1024, - from_as_string: '1024.0', - to: 2560, - to_as_string: '2560.0', - doc_count: 23358, - }, - '2560.0-*': { - from: 2560, - from_as_string: '2560.0', - doc_count: 174250, - }, - }, - }, - }, -}; diff --git a/src/fixtures/config_upgrade_from_4.0.0.json b/src/fixtures/config_upgrade_from_4.0.0.json deleted file mode 100644 index 522de78648c9bd..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 1, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json deleted file mode 100644 index 8767232dcdc1c0..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1-SNAPSHOT", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json deleted file mode 100644 index 57b486491b397a..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/fake_chart_events.js b/src/fixtures/fake_chart_events.js deleted file mode 100644 index 71f49cb4713b86..00000000000000 --- a/src/fixtures/fake_chart_events.js +++ /dev/null @@ -1,28 +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. - */ - -const results = {}; - -results.timeSeries = { - data: { - ordered: { - date: true, - interval: 600000, - max: 1414437217559, - min: 1414394017559, - }, - }, - label: 'apache', - value: 44, - point: { - label: 'apache', - x: 1414400400000, - y: 44, - y0: 0, - }, -}; diff --git a/src/fixtures/fake_hierarchical_data.ts b/src/fixtures/fake_hierarchical_data.ts deleted file mode 100644 index 2e23acfc3a8032..00000000000000 --- a/src/fixtures/fake_hierarchical_data.ts +++ /dev/null @@ -1,621 +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. - */ - -export const metricOnly = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_1: { value: 412032 }, - }, -}; - -export const threeTermBuckets = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_2: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'IT', - doc_count: 10, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 4, agg_1: { value: 0 } }, - { key: 'mac', doc_count: 6, agg_1: { value: 9299 } }, - ], - }, - }, - { - key: 'US', - doc_count: 20, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 8, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'MX', - doc_count: 7, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 4, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'US', - doc_count: 13, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 1, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'CN', - doc_count: 85, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 46, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 39, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'FR', - doc_count: 15, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 12, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_3: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 23, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 203 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 39, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 200 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 329, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 103 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 22, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 153 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 93, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 35, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 239 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 72, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 75, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 11, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 24 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 238, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 49 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 343, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 100 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 837, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 5, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 23 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 302, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 10, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 30, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 20, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 43, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 30, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 5 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 88, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 11, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 91, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 12, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 43 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 534, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 7, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 553, - }, - }, - ], - }, - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneRangeBucket = { - took: 35, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6039, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - '0.0-1000.0': { - from: 0, - from_as_string: '0.0', - to: 1000, - to_as_string: '1000.0', - doc_count: 606, - }, - '1000.0-2000.0': { - from: 1000, - from_as_string: '1000.0', - to: 2000, - to_as_string: '2000.0', - doc_count: 298, - }, - }, - }, - }, -}; - -export const oneFilterBucket = { - took: 11, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6005, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - 'type:apache': { - doc_count: 4844, - }, - 'type:nginx': { - doc_count: 1161, - }, - }, - }, - }, -}; - -export const oneHistogramBucket = { - took: 37, - timed_out: false, - _shards: { - total: 6, - successful: 6, - failed: 0, - }, - hits: { - total: 49208, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 8247, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 8184, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 8269, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 8141, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 8148, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 8219, - }, - ], - }, - }, -}; diff --git a/src/fixtures/field_mapping.js b/src/fixtures/field_mapping.js deleted file mode 100644 index 5077e361d5458d..00000000000000 --- a/src/fixtures/field_mapping.js +++ /dev/null @@ -1,68 +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. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - not_analyzed_field: { - full_name: 'not_analyzed_field', - mapping: { - bar: { - type: 'string', - index: 'not_analyzed', - }, - }, - }, - index_no_field: { - full_name: 'index_no_field', - mapping: { - bar: { - type: 'string', - index: 'no', - }, - }, - }, - _id: { - full_name: '_id', - mapping: { - _id: { - store: false, - index: 'no', - }, - }, - }, - _timestamp: { - full_name: '_timestamp', - mapping: { - _timestamp: { - store: true, - index: 'no', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/hits.js b/src/fixtures/hits.js deleted file mode 100644 index af8264e320909b..00000000000000 --- a/src/fixtures/hits.js +++ /dev/null @@ -1,41 +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. - */ - -export default function fitsFixture() { - return [ - // extension - // | machine.os - //timestamp | | bytes - //| ssl ip | | | request - [0, true, '192.168.0.1', 'php', 'Linux', 10, 'foo'], - [1, true, '192.168.0.1', 'php', 'Linux', 20, 'bar'], - [2, true, '192.168.0.1', 'php', 'Linux', 30, 'bar'], - [3, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [4, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [5, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [6, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [7, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [8, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [9, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - ].map((row, i) => { - return { - _score: 1, - _id: 1000 + i, - _index: 'test-index', - _source: { - '@timestamp': row[0], - ssl: row[1], - ip: row[2], - extension: row[3], - 'machine.os': row[4], - bytes: row[5], - request: row[6], - }, - }; - }); -} diff --git a/src/fixtures/mapping_with_dupes.js b/src/fixtures/mapping_with_dupes.js deleted file mode 100644 index 7f6da2600c9a81..00000000000000 --- a/src/fixtures/mapping_with_dupes.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - }, - }, - }, - duplicates: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'date', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/mock_index_patterns.js b/src/fixtures/mock_index_patterns.js deleted file mode 100644 index ce44b71613b010..00000000000000 --- a/src/fixtures/mock_index_patterns.js +++ /dev/null @@ -1,19 +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 sinon from 'sinon'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function (Private) { - const indexPatterns = Private(FixturesStubbedLogstashIndexPatternProvider); - const getIndexPatternStub = sinon.stub().resolves(indexPatterns); - - return { - get: getIndexPatternStub, - }; -} diff --git a/src/fixtures/mock_state.js b/src/fixtures/mock_state.js deleted file mode 100644 index cb18dac7b767d0..00000000000000 --- a/src/fixtures/mock_state.js +++ /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 _ from 'lodash'; -import sinon from 'sinon'; - -function MockState(defaults) { - this.on = _.noop; - this.off = _.noop; - this.save = sinon.stub(); - this.replace = sinon.stub(); - _.assign(this, defaults); -} - -export default MockState; diff --git a/src/fixtures/mock_ui_state.js b/src/fixtures/mock_ui_state.js deleted file mode 100644 index fc0a18137a5fde..00000000000000 --- a/src/fixtures/mock_ui_state.js +++ /dev/null @@ -1,33 +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 { set } from '@elastic/safer-lodash-set'; -import _ from 'lodash'; -let values = {}; -export default { - get: function (path, def) { - return _.get(values, path, def); - }, - set: function (path, val) { - set(values, path, val); - return val; - }, - setSilent: function (path, val) { - set(values, path, val); - return val; - }, - emit: _.noop, - on: _.noop, - off: _.noop, - clearAllKeys: function () { - values = {}; - }, - _reset: function () { - values = {}; - }, -}; diff --git a/src/fixtures/search_response.js b/src/fixtures/search_response.js deleted file mode 100644 index a84bd184990e0c..00000000000000 --- a/src/fixtures/search_response.js +++ /dev/null @@ -1,24 +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 hits from 'fixtures/real_hits'; - -export default { - took: 73, - timed_out: false, - _shards: { - total: 144, - successful: 144, - failed: 0, - }, - hits: { - total: 49487, - max_score: 1.0, - hits: hits, - }, -}; diff --git a/src/fixtures/stubbed_search_source.js b/src/fixtures/stubbed_search_source.js deleted file mode 100644 index ea41e7bbe681c8..00000000000000 --- a/src/fixtures/stubbed_search_source.js +++ /dev/null @@ -1,54 +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 sinon from 'sinon'; -import searchResponse from 'fixtures/search_response'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function stubSearchSource(Private, $q, Promise) { - let deferedResult = $q.defer(); - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - let onResultsCount = 0; - return { - setField: sinon.spy(), - fetch: sinon.spy(), - destroy: sinon.spy(), - getField: function (param) { - switch (param) { - case 'index': - return indexPattern; - default: - throw new Error(`Param "${param}" is not implemented in the stubbed search source`); - } - }, - crankResults: function () { - deferedResult.resolve(searchResponse); - deferedResult = $q.defer(); - }, - onResults: function () { - onResultsCount++; - - // Up to the test to resolve this manually - // For example: - // someHandler.resolve(require('fixtures/search_response')) - return deferedResult.promise; - }, - getOnResultsCount: function () { - return onResultsCount; - }, - _flatten: function () { - return Promise.resolve({ index: indexPattern, body: {} }); - }, - _requestStartHandlers: [], - onRequestStart(fn) { - this._requestStartHandlers.push(fn); - }, - requestIsStopped() {}, - }; -} diff --git a/src/fixtures/fake_row.js b/src/plugins/discover/public/__fixtures__/fake_row.js similarity index 100% rename from src/fixtures/fake_row.js rename to src/plugins/discover/public/__fixtures__/fake_row.js diff --git a/src/fixtures/logstash_fields.js b/src/plugins/discover/public/__fixtures__/logstash_fields.js similarity index 96% rename from src/fixtures/logstash_fields.js rename to src/plugins/discover/public/__fixtures__/logstash_fields.js index 6303c83d809c0d..a51e1555421ded 100644 --- a/src/fixtures/logstash_fields.js +++ b/src/plugins/discover/public/__fixtures__/logstash_fields.js @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../plugins/data/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../../../data/server'; function stubbedLogstashFields() { return [ diff --git a/src/fixtures/real_hits.js b/src/plugins/discover/public/__fixtures__/real_hits.js similarity index 100% rename from src/fixtures/real_hits.js rename to src/plugins/discover/public/__fixtures__/real_hits.js diff --git a/src/fixtures/stubbed_logstash_index_pattern.js b/src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js similarity index 81% rename from src/fixtures/stubbed_logstash_index_pattern.js rename to src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js index 3451fb5422ecd6..c8513176d1c966 100644 --- a/src/fixtures/stubbed_logstash_index_pattern.js +++ b/src/plugins/discover/public/__fixtures__/stubbed_logstash_index_pattern.js @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from './logstash_fields'; +import { getKbnFieldType } from '../../../data/common'; -import { getKbnFieldType } from '../plugins/data/common'; -import { getStubIndexPattern } from '../plugins/data/public/test_utils'; -import { uiSettingsServiceMock } from '../core/public/ui_settings/ui_settings_service.mock'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getStubIndexPattern } from '../../../data/public/test_utils'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; const uiSettingSetupMock = uiSettingsServiceMock.createSetupContract(); uiSettingSetupMock.get.mockImplementation((item, defaultValue) => { diff --git a/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts b/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts index b8ce93c45e54a2..a0c0b1f2c816e8 100644 --- a/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/stubbed_saved_object_index_pattern.ts @@ -7,7 +7,7 @@ */ // @ts-expect-error -import stubbedLogstashFields from '../../../../fixtures/logstash_fields'; +import stubbedLogstashFields from '../__fixtures__/logstash_fields'; const mockLogstashFields = stubbedLogstashFields(); diff --git a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js index 33772f730912ae..1824110c85b1a9 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js +++ b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js @@ -12,9 +12,9 @@ import 'angular-sanitize'; import 'angular-route'; import _ from 'lodash'; import sinon from 'sinon'; -import { getFakeRow } from 'fixtures/fake_row'; +import { getFakeRow } from '../../../../__fixtures__/fake_row'; import $ from 'jquery'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { setScopedHistory, setServices, setDocViewsRegistry } from '../../../../kibana_services'; import { coreMock } from '../../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../../data/public/mocks'; diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js index cec8d72fbe77fd..1765bae07eed72 100644 --- a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js @@ -12,8 +12,8 @@ import 'angular-mocks'; import 'angular-sanitize'; import 'angular-route'; import { createBrowserHistory } from 'history'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -import hits from 'fixtures/real_hits'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../__fixtures__/stubbed_logstash_index_pattern'; +import hits from '../../../__fixtures__/real_hits'; import { coreMock } from '../../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../../data/public/mocks'; import { navigationPluginMock } from '../../../../../navigation/public/mocks'; diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts index 899c3cc2d41332..c73656435fb588 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.test.ts @@ -8,7 +8,7 @@ import { getDefaultSort } from './get_default_sort'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; describe('getDefaultSort function', function () { diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts index cf8fa67e545660..bd28987b4fdbd9 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts @@ -8,7 +8,7 @@ import { getSort, getSortArray } from './get_sort'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; describe('docTable', function () { diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts index 1d965a176b99d6..f0a13557af9fd4 100644 --- a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.test.ts @@ -8,7 +8,7 @@ import { getSortForSearchSource } from './get_sort_for_search_source'; // @ts-ignore -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import FixturesStubbedLogstashIndexPatternProvider from '../../../../__fixtures__/stubbed_logstash_index_pattern'; import { IndexPattern } from '../../../../kibana_services'; import { SortOrder } from '../components/table_header/helpers'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index baec882fc62428..c16dab618b284c 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { DiscoverField } from './discover_field'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx index 29bd4ce5b2b7d6..0113213f70c880 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { DiscoverFieldDetails } from './discover_field_details'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx index a82c3d740e7edb..07baeddf034eff 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details_footer.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../core/public/mocks'; import { IndexPatternField } from '../../../../../data/public'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 0ff70585af1448..947972ce1cfc50 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -10,9 +10,9 @@ import { each, cloneDeep } from 'lodash'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { DiscoverSidebarProps } from './discover_sidebar'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx index 02ab5abade7fba..7b12ab5f9bcd9e 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_responsive.test.tsx @@ -10,9 +10,9 @@ import { each, cloneDeep } from 'lodash'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../__fixtures__/logstash_fields'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { coreMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts index 94464c309251da..faa31dde1bb80d 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts @@ -8,9 +8,9 @@ import _ from 'lodash'; // @ts-ignore -import realHits from 'fixtures/real_hits.js'; +import realHits from '../../../../__fixtures__/real_hits.js'; // @ts-ignore -import stubbedLogstashFields from 'fixtures/logstash_fields'; +import stubbedLogstashFields from '../../../../__fixtures__/logstash_fields'; import { coreMock } from '../../../../../../../core/public/mocks'; import { IndexPattern } from '../../../../../../data/public'; import { getStubIndexPattern } from '../../../../../../data/public/test_utils'; diff --git a/src/plugins/visualizations/public/__fixtures__/logstash_fields.js b/src/plugins/visualizations/public/__fixtures__/logstash_fields.js new file mode 100644 index 00000000000000..a51e1555421ded --- /dev/null +++ b/src/plugins/visualizations/public/__fixtures__/logstash_fields.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { shouldReadFieldFromDocValues, castEsToKbnFieldTypeName } from '../../../data/server'; + +function stubbedLogstashFields() { + return [ + // |aggregatable + // | |searchable + // name esType | | |metadata | subType + ['bytes', 'long', true, true, { count: 10 }], + ['ssl', 'boolean', true, true, { count: 20 }], + ['@timestamp', 'date', true, true, { count: 30 }], + ['time', 'date', true, true, { count: 30 }], + ['@tags', 'keyword', true, true], + ['utc_time', 'date', true, true], + ['phpmemory', 'integer', true, true], + ['ip', 'ip', true, true], + ['request_body', 'attachment', true, true], + ['point', 'geo_point', true, true], + ['area', 'geo_shape', true, true], + ['hashed', 'murmur3', false, true], + ['geo.coordinates', 'geo_point', true, true], + ['extension', 'text', true, true], + ['extension.keyword', 'keyword', true, true, {}, { multi: { parent: 'extension' } }], + ['machine.os', 'text', true, true], + ['machine.os.raw', 'keyword', true, true, {}, { multi: { parent: 'machine.os' } }], + ['geo.src', 'keyword', true, true], + ['_id', '_id', true, true], + ['_type', '_type', true, true], + ['_source', '_source', true, true], + ['non-filterable', 'text', true, false], + ['non-sortable', 'text', false, false], + ['custom_user_field', 'conflict', true, true], + ['script string', 'text', true, false, { script: "'i am a string'" }], + ['script number', 'long', true, false, { script: '1234' }], + ['script date', 'date', true, false, { script: '1234', lang: 'painless' }], + ['script murmur3', 'murmur3', true, false, { script: '1234' }], + ].map(function (row) { + const [name, esType, aggregatable, searchable, metadata = {}, subType = undefined] = row; + + const { + count = 0, + script, + lang = script ? 'expression' : undefined, + scripted = !!script, + } = metadata; + + // the conflict type is actually a kbnFieldType, we + // don't have any other way to represent it here + const type = esType === 'conflict' ? esType : castEsToKbnFieldTypeName(esType); + + return { + name, + type, + esTypes: [esType], + readFromDocValues: shouldReadFieldFromDocValues(aggregatable, esType), + aggregatable, + searchable, + count, + script, + lang, + scripted, + subType, + }; + }); +} + +export default stubbedLogstashFields; diff --git a/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js b/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js new file mode 100644 index 00000000000000..c8513176d1c966 --- /dev/null +++ b/src/plugins/visualizations/public/__fixtures__/stubbed_logstash_index_pattern.js @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 stubbedLogstashFields from './logstash_fields'; +import { getKbnFieldType } from '../../../data/common'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getStubIndexPattern } from '../../../data/public/test_utils'; +import { uiSettingsServiceMock } from '../../../../core/public/mocks'; + +const uiSettingSetupMock = uiSettingsServiceMock.createSetupContract(); +uiSettingSetupMock.get.mockImplementation((item, defaultValue) => { + return defaultValue; +}); + +export default function stubbedLogstashIndexPatternService() { + const mockLogstashFields = stubbedLogstashFields(); + + const fields = mockLogstashFields.map(function (field) { + const kbnType = getKbnFieldType(field.type); + + if (!kbnType || kbnType.name === 'unknown') { + throw new TypeError(`unknown type ${field.type}`); + } + + return { + ...field, + sortable: 'sortable' in field ? !!field.sortable : kbnType.sortable, + filterable: 'filterable' in field ? !!field.filterable : kbnType.filterable, + displayName: field.name, + }; + }); + + const indexPattern = getStubIndexPattern('logstash-*', (cfg) => cfg, 'time', fields, { + uiSettings: uiSettingSetupMock, + }); + + indexPattern.id = 'logstash-*'; + indexPattern.isTimeNanosBased = () => false; + + return indexPattern; +} diff --git a/src/plugins/visualizations/public/vis.test.ts b/src/plugins/visualizations/public/vis.test.ts index b90e5effeb8a50..45c5bb6b979c69 100644 --- a/src/plugins/visualizations/public/vis.test.ts +++ b/src/plugins/visualizations/public/vis.test.ts @@ -26,7 +26,7 @@ jest.mock('./services', () => { // eslint-disable-next-line const { SearchSource } = require('../../data/common/search/search_source'); // eslint-disable-next-line - const fixturesStubbedLogstashIndexPatternProvider = require('../../../fixtures/stubbed_logstash_index_pattern'); + const fixturesStubbedLogstashIndexPatternProvider = require('./__fixtures__/stubbed_logstash_index_pattern'); const visType = new BaseVisType({ name: 'pie', title: 'pie', diff --git a/src/type_definitions/react_virtualized.d.ts b/src/type_definitions/react_virtualized.d.ts deleted file mode 100644 index d78a159b715609..00000000000000 --- a/src/type_definitions/react_virtualized.d.ts +++ /dev/null @@ -1,11 +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. - */ - -declare module 'react-virtualized' { - export type ListProps = any; -} diff --git a/tsconfig.base.json b/tsconfig.base.json index f8e07911e71cec..c63d43b4cb6ad5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,8 +5,7 @@ // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], - "kibana/server": ["src/core/server"], - "fixtures/*": ["src/fixtures/*"] + "kibana/server": ["src/core/server"] }, // Support .tsx files and transform JSX into calls to React.createElement "jsx": "react", diff --git a/tsconfig.json b/tsconfig.json index f6e0fbc8d9e971..48feac3efe4752 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,65 +7,7 @@ "exclude": [ "src/**/__fixtures__/**/*", "src/core/**/*", - "src/plugins/telemetry_management_section/**/*", - "src/plugins/advanced_settings/**/*", - "src/plugins/apm_oss/**/*", - "src/plugins/bfetch/**/*", - "src/plugins/charts/**/*", - "src/plugins/console/**/*", - "src/plugins/dashboard/**/*", - "src/plugins/discover/**/*", - "src/plugins/data/**/*", - "src/plugins/dev_tools/**/*", - "src/plugins/embeddable/**/*", - "src/plugins/es_ui_shared/**/*", - "src/plugins/expressions/**/*", - "src/plugins/home/**/*", - "src/plugins/input_control_vis/**/*", - "src/plugins/inspector/**/*", - "src/plugins/kibana_legacy/**/*", - "src/plugins/kibana_overview/**/*", - "src/plugins/kibana_react/**/*", - "src/plugins/kibana_usage_collection/**/*", - "src/plugins/kibana_utils/**/*", - "src/plugins/legacy_export/**/*", - "src/plugins/management/**/*", - "src/plugins/maps_legacy/**/*", - "src/plugins/navigation/**/*", - "src/plugins/newsfeed/**/*", - "src/plugins/region_map/**/*", - "src/plugins/saved_objects/**/*", - "src/plugins/saved_objects_management/**/*", - "src/plugins/saved_objects_tagging_oss/**/*", - "src/plugins/security_oss/**/*", - "src/plugins/share/**/*", - "src/plugins/spaces_oss/**/*", - "src/plugins/telemetry/**/*", - "src/plugins/telemetry_collection_manager/**/*", - "src/plugins/tile_map/**/*", - "src/plugins/timelion/**/*", - "src/plugins/ui_actions/**/*", - "src/plugins/url_forwarding/**/*", - "src/plugins/usage_collection/**/*", - "src/plugins/presentation_util/**/*", - "src/plugins/vis_default_editor/**/*", - "src/plugins/vis_type_markdown/**/*", - "src/plugins/vis_type_metric/**/*", - "src/plugins/vis_type_table/**/*", - "src/plugins/vis_type_tagcloud/**/*", - "src/plugins/vis_type_timelion/**/*", - "src/plugins/vis_type_timeseries/**/*", - "src/plugins/vis_type_vislib/**/*", - "src/plugins/vis_type_vega/**/*", - "src/plugins/vis_type_xy/**/*", - "src/plugins/visualizations/**/*", - "src/plugins/visualize/**/*", - "src/plugins/index_pattern_management/**/*", - // In the build we actually exclude **/public/**/* from this config so that - // we can run the TSC on both this and the .browser version of this config - // file, but if we did it during development IDEs would not be able to find - // the tsconfig.json file for public files correctly. - // "src/**/public/**/*" + "src/plugins/**/*" ], "references": [ { "path": "./src/core/tsconfig.json" }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts index 33c993ffdad40d..4c4433c2b4f89e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/use_request.ts @@ -19,7 +19,10 @@ let httpClient: HttpSetup; export type UseRequestConfig = _UseRequestConfig; -interface RequestError extends Error { +/** + * @internal + */ +export interface RequestError extends Error { statusCode?: number; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx index 72e6601a023e1e..d219384f66cef3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/fleet_start_services.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; +import { MockedKeys } from '@kbn/utility-types/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { createStartDepsMock } from './plugin_dependencies'; import { IStorage, Storage } from '../../../../../../../src/plugins/kibana_utils/public'; -import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index'; import { setHttpClient } from '../hooks/use_request'; import { MockedFleetStartServices } from './types'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts index 9e0adf75c0a35c..0a55fa43bf18da 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { MockedKeys } from '../../../../../../../packages/kbn-utility-types/jest/index'; +import { MockedKeys } from '@kbn/utility-types/jest'; import { FleetSetupDeps, FleetStart, FleetStartDeps, FleetStartServices } from '../../../plugin'; export type MockedFleetStartServices = MockedKeys; diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 92159c1ced7c3c..c650995c809cbb 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -10,6 +10,9 @@ import { loggingSystemMock, savedObjectsServiceMock, } from 'src/core/server/mocks'; +import { coreMock } from '../../../../src/core/server/mocks'; +import { licensingMock } from '../../../plugins/licensing/server/mocks'; + import { FleetAppContext } from './plugin'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { securityMock } from '../../security/server/mocks'; @@ -29,6 +32,17 @@ export const createAppContextStartContractMock = (): FleetAppContext => { }; }; +function createCoreRequestHandlerContextMock() { + return { + core: coreMock.createRequestHandlerContext(), + licensing: licensingMock.createRequestHandlerContext(), + }; +} + +export const xpackMocks = { + createRequestHandlerContext: createCoreRequestHandlerContextMock, +}; + export const createPackagePolicyServiceMock = () => { return { compilePackagePolicyInputs: jest.fn(), diff --git a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts b/x-pack/plugins/fleet/server/routes/limited_concurrency.ts index 45af0a3b7eaab5..92195ae08681ad 100644 --- a/x-pack/plugins/fleet/server/routes/limited_concurrency.ts +++ b/x-pack/plugins/fleet/server/routes/limited_concurrency.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { +import type { CoreSetup, KibanaRequest, LifecycleResponseFactory, OnPreAuthToolkit, + OnPreAuthHandler, } from 'kibana/server'; import { LIMITED_CONCURRENCY_ROUTE_TAG } from '../../common'; import { FleetConfigType } from '../index'; @@ -48,7 +49,7 @@ export function createLimitedPreAuthHandler({ }: { isMatch: (request: KibanaRequest) => boolean; maxCounter: IMaxCounter; -}) { +}): OnPreAuthHandler { return function preAuthHandler( request: KibanaRequest, response: LifecycleResponseFactory, diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index df99f2fba7ed93..2b44975cc3b4de 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -9,9 +9,8 @@ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; import { IRouter, KibanaRequest, RequestHandler, RouteConfig } from 'kibana/server'; import { registerRoutes } from './index'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; -import { xpackMocks } from '../../../../../mocks'; import { appContextService } from '../../services'; -import { createAppContextStartContractMock } from '../../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import { PackagePolicyServiceInterface, ExternalCallback } from '../..'; import { CreatePackagePolicyRequestSchema } from '../../types/rest_spec'; import { packagePolicyService } from '../../services'; diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts index af9596849fd7a2..946f17ad8129d5 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.test.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { xpackMocks } from '../../../../../../x-pack/mocks'; import { httpServerMock } from 'src/core/server/mocks'; import { PostIngestSetupResponse } from '../../../common'; import { RegistryError } from '../../errors'; -import { createAppContextStartContractMock } from '../../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; import { FleetSetupHandler } from './handlers'; import { appContextService } from '../../services/app_context'; import { setupIngestManager } from '../../services/setup'; diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index d50db8d9809f4d..f2eb8be5c030c6 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -10,7 +10,8 @@ import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objec import { migratePackagePolicyToV7110, migratePackagePolicyToV7120, -} from '../../../security_solution/common'; + // @ts-expect-error +} from './security_solution'; import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_POLICY_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/fleet/server/saved_objects/security_solution.js b/x-pack/plugins/fleet/server/saved_objects/security_solution.js new file mode 100644 index 00000000000000..63f70ba783c0c0 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/security_solution.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + migratePackagePolicyToV7110, + migratePackagePolicyToV7120, +} from '../../../security_solution/common'; diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index f63282f8ed7c65..02e4fceea54f9e 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -7,6 +7,8 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; +import { kibanaPackageJSON } from '@kbn/utils'; + import { ElasticsearchClient, SavedObjectsServiceStart, @@ -18,7 +20,6 @@ import { EncryptedSavedObjectsClient, EncryptedSavedObjectsPluginSetup, } from '../../../encrypted_saved_objects/server'; -import packageJSON from '../../../../../package.json'; import { SecurityPluginStart } from '../../../security/server'; import { FleetConfigType } from '../../common'; import { ExternalCallback, ExternalCallbacksStorage, FleetAppContext } from '../plugin'; @@ -33,8 +34,8 @@ class AppContextService { private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; private isProductionMode: FleetAppContext['isProductionMode'] = false; - private kibanaVersion: FleetAppContext['kibanaVersion'] = packageJSON.version; - private kibanaBranch: FleetAppContext['kibanaBranch'] = packageJSON.branch; + private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJSON.version; + private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJSON.branch; private cloud?: CloudSetup; private logger: Logger | undefined; private httpSetup?: HttpServiceSetup; diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 1f2666dc14d1f8..604592a0a8d878 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -12,10 +12,9 @@ import { PackageInfo, PackagePolicySOAttributes } from '../types'; import { SavedObjectsUpdateResponse } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; import { KibanaRequest } from 'kibana/server'; -import { xpackMocks } from '../../../../mocks'; import { ExternalCallback } from '..'; import { appContextService } from './app_context'; -import { createAppContextStartContractMock } from '../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../mocks'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 8d1ac90f3ec158..a882ceb0037f24 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -565,3 +565,5 @@ async function _compilePackageStream( export type PackagePolicyServiceInterface = PackagePolicyService; export const packagePolicyService = new PackagePolicyService(); + +export type { PackagePolicyService }; diff --git a/x-pack/plugins/fleet/server/services/setup.test.ts b/x-pack/plugins/fleet/server/services/setup.test.ts index a4df30b97a4439..479f28fa0a1ed4 100644 --- a/x-pack/plugins/fleet/server/services/setup.test.ts +++ b/x-pack/plugins/fleet/server/services/setup.test.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { xpackMocks } from '../../../../../x-pack/mocks'; -import { createAppContextStartContractMock } from '../mocks'; +import { createAppContextStartContractMock, xpackMocks } from '../mocks'; import { appContextService } from './app_context'; import { setupIngestManager } from './setup'; diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 3a37b14410424c..152fb2e132f626 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./target/types", @@ -12,7 +12,9 @@ "common/**/*", "public/**/*", "server/**/*", - "scripts/**/*" + "scripts/**/*", + "package.json", + "../../typings/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 61678337625835..407830d6a6c219 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./target/types", diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 6b874f62538438..2c475083b589a8 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -1,69 +1,24 @@ { "extends": "../tsconfig.base.json", - "include": ["mocks.ts", "typings/**/*", "plugins/**/*", "tasks/**/*"], + "include": [ + "mocks.ts", + "typings/**/*", + "tasks/**/*", + "plugins/apm/**/*", + "plugins/case/**/*", + "plugins/lists/**/*", + "plugins/logstash/**/*", + "plugins/monitoring/**/*", + "plugins/security_solution/**/*", + "plugins/xpack_legacy/**/*", + "plugins/drilldowns/url_drilldown/**/*" + ], "exclude": [ - "plugins/actions/**/*", - "plugins/alerts/**/*", + "test/**/*", "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", - "plugins/banners/**/*", - "plugins/canvas/**/*", - "plugins/console_extensions/**/*", - "plugins/code/**/*", - "plugins/data_enhanced/**/*", - "plugins/discover_enhanced/**/*", - "plugins/dashboard_mode/**/*", - "plugins/dashboard_enhanced/**/*", - "plugins/fleet/**/*", - "plugins/global_search/**/*", - "plugins/global_search_providers/**/*", - "plugins/graph/**/*", - "plugins/features/**/*", - "plugins/file_upload/**/*", - "plugins/embeddable_enhanced/**/*", - "plugins/event_log/**/*", - "plugins/enterprise_search/**/*", - "plugins/infra/**/*", - "plugins/licensing/**/*", - "plugins/lens/**/*", - "plugins/maps/**/*", - "plugins/maps_legacy_licensing/**/*", - "plugins/ml/**/*", - "plugins/observability/**/*", - "plugins/osquery/**/*", - "plugins/reporting/**/*", - "plugins/searchprofiler/**/*", - "plugins/security_solution/cypress/**/*", - "plugins/task_manager/**/*", - "plugins/telemetry_collection_xpack/**/*", - "plugins/transform/**/*", - "plugins/translations/**/*", - "plugins/triggers_actions_ui/**/*", - "plugins/ui_actions_enhanced/**/*", - "plugins/spaces/**/*", - "plugins/security/**/*", - "plugins/stack_alerts/**/*", - "plugins/encrypted_saved_objects/**/*", - "plugins/beats_management/**/*", - "plugins/cloud/**/*", - "plugins/saved_objects_tagging/**/*", - "plugins/global_search_bar/**/*", - "plugins/ingest_pipelines/**/*", - "plugins/license_management/**/*", - "plugins/snapshot_restore/**/*", - "plugins/painless_lab/**/*", - "plugins/watcher/**/*", - "plugins/runtime_fields/**/*", - "plugins/index_management/**/*", - "plugins/grokdebugger/**/*", - "plugins/upgrade_assistant/**/*", - "plugins/rollup/**/*", - "plugins/remote_clusters/**/*", - "plugins/cross_cluster_replication/**/*", - "plugins/index_lifecycle_management/**/*", - "plugins/uptime/**/*", - "test/**/*" + "plugins/security_solution/cypress/**/*" ], "compilerOptions": { // overhead is too significant @@ -121,6 +76,7 @@ { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/file_upload/tsconfig.json" }, + { "path": "./plugins/fleet/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, { "path": "./plugins/global_search/tsconfig.json" }, From ee86a3b52b1175d8779a88c4e0cb1ebfda60b208 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 11 Feb 2021 09:50:16 -0800 Subject: [PATCH 10/26] Changing the saved-object usage collector's alias from text to keyword (#91064) --- .../server/collectors/core/core_usage_collector.ts | 2 +- src/plugins/telemetry/schema/oss_plugins.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index c94567e74d7f5d..efd2d2e5629017 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -97,7 +97,7 @@ export function getCoreUsageCollector( items: { docsCount: { type: 'long' }, docsDeleted: { type: 'long' }, - alias: { type: 'text' }, + alias: { type: 'keyword' }, primaryStoreSizeBytes: { type: 'long' }, storeSizeBytes: { type: 'long' }, }, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 14cd7141ac9e21..c7849db147424e 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -3702,7 +3702,7 @@ "type": "long" }, "alias": { - "type": "text" + "type": "keyword" }, "primaryStoreSizeBytes": { "type": "long" From f85be6b36b1339eb199e83adbf21eca8a92535db Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 11 Feb 2021 10:22:12 -0800 Subject: [PATCH 11/26] Add custom saved-object index usage data (#91063) * Add custom saved-object index usage data * Fixing mock and test * Updating docs --- .../core_usage_data_service.mock.ts | 1 + .../core_usage_data_service.test.ts | 1 + .../core_usage_data/core_usage_data_service.ts | 14 ++++++++++++++ src/core/server/core_usage_data/types.ts | 1 + src/core/server/server.api.md | 1 + .../server/collectors/core/core_usage_collector.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 3 +++ 7 files changed, 22 insertions(+) diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index 1a0706917b5dd8..21a599e45da013 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -105,6 +105,7 @@ const createStartContractMock = () => { loggersConfiguredCount: 0, }, savedObjects: { + customIndex: false, maxImportExportSizeBytes: 10000, maxImportPayloadBytes: 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 5a9a68c9e4ece8..9086d73b778071 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -238,6 +238,7 @@ describe('CoreUsageDataService', () => { "loggersConfiguredCount": 0, }, "savedObjects": Object { + "customIndex": false, "maxImportExportSizeBytes": 10000, "maxImportPayloadBytes": 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index a4c6c6e8c66f46..bd5f23b1c09bc7 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -59,6 +59,19 @@ const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; }; +/** + * This is incredibly hacky... The config service doesn't allow you to determine + * whether or not a config value has been changed from the default value, and the + * default value is defined in legacy code. + * + * This will be going away in 8.0, so please look away for a few months + * + * @param index The `kibana.index` setting from the `kibana.yml` + */ +const isCustomIndex = (index: string) => { + return index !== '.kibana'; +}; + export class CoreUsageDataService implements CoreService { private logger: Logger; private elasticsearchConfig?: ElasticsearchConfigType; @@ -220,6 +233,7 @@ export class CoreUsageDataService implements CoreService Date: Thu, 11 Feb 2021 12:33:22 -0600 Subject: [PATCH 12/26] [Workplace Search] Port bugfix to handle duplicate schema (#91055) Ports https://github.com/elastic/ent-search/pull/3040 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/schema/schema_logic.test.ts | 27 ++++++++++++------- .../components/schema/schema_logic.ts | 21 ++++++++++++--- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 28850531ebb94f..74e3337e9600af 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -307,16 +307,25 @@ describe('SchemaLogic', () => { }); }); - it('addNewField', () => { - const setServerFieldSpy = jest.spyOn(SchemaLogic.actions, 'setServerField'); - SchemaLogic.actions.onInitializeSchema(serverResponse); - const newSchema = { - ...schema, - bar: 'number', - }; - SchemaLogic.actions.addNewField('bar', 'number'); + describe('addNewField', () => { + it('handles happy path', () => { + const setServerFieldSpy = jest.spyOn(SchemaLogic.actions, 'setServerField'); + SchemaLogic.actions.onInitializeSchema(serverResponse); + const newSchema = { + ...schema, + bar: 'number', + }; + SchemaLogic.actions.addNewField('bar', 'number'); + + expect(setServerFieldSpy).toHaveBeenCalledWith(newSchema, ADD); + }); - expect(setServerFieldSpy).toHaveBeenCalledWith(newSchema, ADD); + it('handles duplicate', () => { + SchemaLogic.actions.onInitializeSchema(serverResponse); + SchemaLogic.actions.addNewField('foo', 'number'); + + expect(setErrorMessage).toHaveBeenCalledWith('New field already exists: foo.'); + }); }); it('updateExistingFieldType', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts index 10b7f85a631bc1..c97c6f5f0c1bec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts @@ -8,6 +8,8 @@ import { kea, MakeLogicType } from 'kea'; import { cloneDeep, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; + import { TEXT } from '../../../../../shared/constants/field_types'; import { ADD, UPDATE } from '../../../../../shared/constants/operations'; import { @@ -300,9 +302,22 @@ export const SchemaLogic = kea>({ } }, addNewField: ({ fieldName, newFieldType }) => { - const schema = cloneDeep(values.activeSchema); - schema[fieldName] = newFieldType; - actions.setServerField(schema, ADD); + if (fieldName in values.activeSchema) { + window.scrollTo(0, 0); + setErrorMessage( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.schema.newFieldExists.message', + { + defaultMessage: 'New field already exists: {fieldName}.', + values: { fieldName }, + } + ) + ); + } else { + const schema = cloneDeep(values.activeSchema); + schema[fieldName] = newFieldType; + actions.setServerField(schema, ADD); + } }, updateExistingFieldType: ({ fieldName, newFieldType }) => { const schema = cloneDeep(values.activeSchema); From 89327bf9de765e96080199f4f1b49b6b9953d6a6 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 11 Feb 2021 13:46:35 -0500 Subject: [PATCH 13/26] [Time to Visualize] Rename Visualize to Visualize Library (#91015) * Renamed Visualize to Visualize Library --- .../components/visualize_listing.tsx | 8 +++---- .../public/application/utils/breadcrumbs.ts | 2 +- .../public/application/utils/utils.ts | 2 +- src/plugins/visualize/public/plugin.ts | 4 ++-- .../apps/dashboard/edit_visualizations.js | 2 +- test/functional/page_objects/header_page.ts | 2 +- .../plugins/features/server/oss_features.ts | 2 +- .../lens/public/app_plugin/app.test.tsx | 24 +++++++++++++++---- x-pack/plugins/lens/public/app_plugin/app.tsx | 2 +- .../apps/lens/persistent_context.ts | 2 +- .../feature_controls/visualize_security.ts | 6 ++--- .../feature_controls/visualize_spaces.ts | 4 ++-- .../functional/apps/visualize/preserve_url.ts | 8 +++---- 13 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 1f1f8c0b5ac80e..87660b64bab611 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -69,12 +69,12 @@ export const VisualizeListing = () => { chrome.setBreadcrumbs([ { text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { - defaultMessage: 'Visualize', + defaultMessage: 'Visualize Library', }), }, ]); chrome.docTitle.change( - i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize' }) + i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize Library' }) ); }); useUnmount(() => closeNewVisModal.current()); @@ -186,7 +186,7 @@ export const VisualizeListing = () => { // for data exploration purposes createItem={createNewVis} tableCaption={i18n.translate('visualize.listing.table.listTitle', { - defaultMessage: 'Visualizations', + defaultMessage: 'Visualize Library', })} findItems={fetchItems} deleteItems={visualizeCapabilities.delete ? deleteItems : undefined} @@ -204,7 +204,7 @@ export const VisualizeListing = () => { defaultMessage: 'visualizations', })} tableListTitle={i18n.translate('visualize.listing.table.listTitle', { - defaultMessage: 'Visualizations', + defaultMessage: 'Visualize Library', })} toastNotifications={toastNotifications} searchFilters={searchFilters} diff --git a/src/plugins/visualize/public/application/utils/breadcrumbs.ts b/src/plugins/visualize/public/application/utils/breadcrumbs.ts index 7fe8528151fdd0..83ef94f26354ae 100644 --- a/src/plugins/visualize/public/application/utils/breadcrumbs.ts +++ b/src/plugins/visualize/public/application/utils/breadcrumbs.ts @@ -18,7 +18,7 @@ export function getLandingBreadcrumbs() { return [ { text: i18n.translate('visualize.listing.breadcrumb', { - defaultMessage: 'Visualize', + defaultMessage: 'Visualize Library', }), href: `#${VisualizeConstants.LANDING_PAGE_PATH}`, }, diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts index 16064682f4449c..0171daa2025292 100644 --- a/src/plugins/visualize/public/application/utils/utils.ts +++ b/src/plugins/visualize/public/application/utils/utils.ts @@ -15,7 +15,7 @@ import { VisualizeServices, VisualizeEditorVisInstance } from '../types'; export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => { chrome.setHelpExtension({ appName: i18n.translate('visualize.helpMenu.appName', { - defaultMessage: 'Visualize', + defaultMessage: 'Visualize Library', }), links: [ { diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 4eb2d6fd2a731c..300afd69c84ccd 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -133,7 +133,7 @@ export class VisualizePlugin core.application.register({ id: VisualizeConstants.APP_ID, - title: 'Visualize', + title: 'Visualize Library', order: 8000, euiIconType: 'logoKibana', defaultPath: '#/', @@ -224,7 +224,7 @@ export class VisualizePlugin if (home) { home.featureCatalogue.register({ id: 'visualize', - title: 'Visualize', + title: 'Visualize Library', description: i18n.translate('visualize.visualizeDescription', { defaultMessage: 'Create visualizations and aggregate data stores in your Elasticsearch indices.', diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index 0996fbe7cf0d78..9d7f4a5a378205 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -104,7 +104,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visualize.saveVisualizationAndReturn(); await PageObjects.header.waitUntilLoadingHasFinished(); - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.common.clickConfirmOnModal(); expect(await testSubjects.exists('visualizationLandingPage')).to.be(true); }); diff --git a/test/functional/page_objects/header_page.ts b/test/functional/page_objects/header_page.ts index a2b66c0bb47128..c5a796a1eb13be 100644 --- a/test/functional/page_objects/header_page.ts +++ b/test/functional/page_objects/header_page.ts @@ -27,7 +27,7 @@ export function HeaderPageProvider({ getService, getPageObjects }: FtrProviderCo } public async clickVisualize(ignoreAppLeaveWarning = false) { - await appsMenu.clickLink('Visualize', { category: 'kibana' }); + await appsMenu.clickLink('Visualize Library', { category: 'kibana' }); await this.onAppLeaveWarning(ignoreAppLeaveWarning); await this.awaitGlobalLoadingIndicatorHidden(); await retry.waitFor('Visualize app to be loaded', async () => { diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 6c599461f438aa..30398feb147550 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -111,7 +111,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS { id: 'visualize', name: i18n.translate('xpack.features.visualizeFeatureName', { - defaultMessage: 'Visualize', + defaultMessage: 'Visualize Library', }), order: 700, category: DEFAULT_APP_CATEGORIES.kibana, diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 168f9e95832407..477bd0a3f0eee3 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -393,7 +393,11 @@ describe('Lens App', () => { const { component, services } = mountWith({}); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ - { text: 'Visualize', href: '/testbasepath/app/visualize#/', onClick: expect.anything() }, + { + text: 'Visualize Library', + href: '/testbasepath/app/visualize#/', + onClick: expect.anything(), + }, { text: 'Create' }, ]); @@ -403,7 +407,11 @@ describe('Lens App', () => { }); expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ - { text: 'Visualize', href: '/testbasepath/app/visualize#/', onClick: expect.anything() }, + { + text: 'Visualize Library', + href: '/testbasepath/app/visualize#/', + onClick: expect.anything(), + }, { text: 'Daaaaaaadaumching!' }, ]); }); @@ -417,7 +425,11 @@ describe('Lens App', () => { expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'The Coolest Container Ever Made', onClick: expect.anything() }, - { text: 'Visualize', href: '/testbasepath/app/visualize#/', onClick: expect.anything() }, + { + text: 'Visualize Library', + href: '/testbasepath/app/visualize#/', + onClick: expect.anything(), + }, { text: 'Create' }, ]); @@ -428,7 +440,11 @@ describe('Lens App', () => { expect(services.chrome.setBreadcrumbs).toHaveBeenCalledWith([ { text: 'The Coolest Container Ever Made', onClick: expect.anything() }, - { text: 'Visualize', href: '/testbasepath/app/visualize#/', onClick: expect.anything() }, + { + text: 'Visualize Library', + href: '/testbasepath/app/visualize#/', + onClick: expect.anything(), + }, { text: 'Daaaaaaadaumching!' }, ]); }); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 0d72a366fa4119..bacb426b02838d 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -278,7 +278,7 @@ export function App({ e.preventDefault(); }, text: i18n.translate('xpack.lens.breadcrumbsTitle', { - defaultMessage: 'Visualize', + defaultMessage: 'Visualize Library', }), }); } diff --git a/x-pack/test/functional/apps/lens/persistent_context.ts b/x-pack/test/functional/apps/lens/persistent_context.ts index 0ed9506149f92b..a3ef8ac33fb9ae 100644 --- a/x-pack/test/functional/apps/lens/persistent_context.ts +++ b/x-pack/test/functional/apps/lens/persistent_context.ts @@ -49,7 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2025 @ 06:31:44.000' ); await filterBar.toggleFilterEnabled('ip'); - await appsMenu.clickLink('Visualize', { category: 'kibana' }); + await appsMenu.clickLink('Visualize Library', { category: 'kibana' }); await PageObjects.visualize.clickNewVisualization(); await PageObjects.visualize.waitForGroupsSelectPage(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index da94eaf19ea3f8..d6644cee211986 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -81,7 +81,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -212,7 +212,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -327,7 +327,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index 5c6ea66f1b0498..469a337177065e 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -44,7 +44,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { basePath: '/s/custom_space', }); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.contain('Visualize'); + expect(navLinks).to.contain('Visualize Library'); }); it(`can view existing Visualization`, async () => { @@ -85,7 +85,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { basePath: '/s/custom_space', }); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).not.to.contain('Visualize'); + expect(navLinks).not.to.contain('Visualize Library'); }); it(`create new visualization shows 404`, async () => { diff --git a/x-pack/test/functional/apps/visualize/preserve_url.ts b/x-pack/test/functional/apps/visualize/preserve_url.ts index b48f82fc0fd2a4..16267a544275c6 100644 --- a/x-pack/test/functional/apps/visualize/preserve_url.ts +++ b/x-pack/test/functional/apps/visualize/preserve_url.ts @@ -27,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('visualize'); await PageObjects.visualize.openSavedVisualization('A Pie'); await PageObjects.common.navigateToApp('home'); - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitle = await globalNav.getLastBreadcrumb(); expect(activeTitle).to.be('A Pie'); @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visualize.openSavedVisualization('A Pie in another space'); await PageObjects.spaceSelector.openSpacesNav(); @@ -51,7 +51,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('default'); // default space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitleDefaultSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleDefaultSpace).to.be('A Pie'); @@ -61,7 +61,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitleOtherSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleOtherSpace).to.be('A Pie in another space'); From 609b5bf1b748cb573526050c45404b399ab3df81 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Thu, 11 Feb 2021 13:49:52 -0500 Subject: [PATCH 14/26] [Dashboard] Adds Dashboard Maps by value functional tests (#90449) * Adds Dashboard Maps by value functional tests * Fix license header issue * License check * Fix duplicate import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/dashboard/dashboard_maps_by_value.ts | 132 ++++++++++++++++++ .../test/functional/apps/dashboard/index.ts | 1 + 2 files changed, 133 insertions(+) create mode 100644 x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts diff --git a/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts new file mode 100644 index 00000000000000..15c76c3367a86d --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'maps', + 'timeToVisualize', + 'visualize', + ]); + + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const testSubjects = getService('testSubjects'); + const appsMenu = getService('appsMenu'); + + const LAYER_NAME = 'World Countries'; + let mapCounter = 0; + + async function createAndAddMapByValue() { + log.debug(`createAndAddMapByValue`); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + await PageObjects.visualize.clickMapsApp(); + await PageObjects.maps.clickSaveAndReturnButton(); + } + + async function editByValueMap(saveToLibrary = false, saveToDashboard = true) { + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + + await dashboardPanelActions.clickEdit(); + await PageObjects.maps.clickAddLayer(); + await PageObjects.maps.selectEMSBoundariesSource(); + await PageObjects.maps.selectVectorLayer(LAYER_NAME); + + if (saveToLibrary) { + await testSubjects.click('importFileButton'); + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.ensureSaveModalIsOpen; + + await PageObjects.timeToVisualize.saveFromModal(`my map ${mapCounter++}`, { + redirectToOrigin: saveToDashboard, + }); + + if (!saveToDashboard) { + await appsMenu.clickLink('Dashboard'); + } + } else { + await PageObjects.maps.clickSaveAndReturnButton(); + } + + await PageObjects.dashboard.waitForRenderComplete(); + } + + async function createNewDashboard() { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + } + + describe('dashboard maps by value', function () { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + }); + + describe('adding a map by value', () => { + it('can add a map by value', async () => { + await createNewDashboard(); + + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(1); + }); + }); + + describe('editing a map by value', () => { + before(async () => { + await createNewDashboard(); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + await editByValueMap(); + }); + + it('retains the same number of panels', async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.equal(1); + }); + + it('updates the panel on return', async () => { + const hasLayer = await PageObjects.maps.doesLayerExist(LAYER_NAME); + expect(hasLayer).to.be(true); + }); + }); + + describe('editing a map and adding to map library', () => { + beforeEach(async () => { + await createNewDashboard(); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + }); + + it('updates the existing panel when adding to dashboard', async () => { + await editByValueMap(true); + + const hasLayer = await PageObjects.maps.doesLayerExist(LAYER_NAME); + + expect(hasLayer).to.be(true); + }); + + it('does not update the panel when only saving to library', async () => { + await editByValueMap(true, false); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/index.ts b/x-pack/test/functional/apps/dashboard/index.ts index 5a8278535922e0..1d046c7c182182 100644 --- a/x-pack/test/functional/apps/dashboard/index.ts +++ b/x-pack/test/functional/apps/dashboard/index.ts @@ -18,5 +18,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./sync_colors')); loadTestFile(require.resolve('./_async_dashboard')); loadTestFile(require.resolve('./dashboard_lens_by_value')); + loadTestFile(require.resolve('./dashboard_maps_by_value')); }); } From 8bd0e3217b0153db8faa76c3140fff992598ffdf Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Thu, 11 Feb 2021 13:51:23 -0500 Subject: [PATCH 15/26] [Canvas] Adds Label option for Dropdown Control (#88505) * Adds Label option for Dropdown Control * Update Snapshots * Fix typecheck --- .../common/__fixtures__/test_tables.ts | 32 ++++++++++++++++++- .../functions/common/dropdownControl.ts | 23 +++++++++---- ...ntrol.test.js => dropdown_control.test.ts} | 21 +++++++++--- .../dropdown_filter.stories.storyshot | 20 ++++++------ .../__stories__/dropdown_filter.stories.tsx | 6 +++- .../component/dropdown_filter.tsx | 4 +-- .../i18n/functions/dict/dropdown_control.ts | 3 ++ 7 files changed, 83 insertions(+), 26 deletions(-) rename x-pack/plugins/canvas/canvas_plugin_src/functions/common/{dropdown_control.test.js => dropdown_control.test.ts} (73%) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_tables.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_tables.ts index 98743dd784d52e..18aa70534b0baf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_tables.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__fixtures__/test_tables.ts @@ -205,4 +205,34 @@ const stringTable: Datatable = { ], }; -export { emptyTable, testTable, stringTable }; +const relationalTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'id', + name: 'id', + meta: { type: 'string' }, + }, + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + ], + rows: [ + { + id: '1', + name: 'One', + }, + { + id: '2', + name: 'Two', + }, + { + id: '3', + name: 'Three', + }, + ], +}; + +export { emptyTable, testTable, stringTable, relationalTable }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts index 16881cbd8ef88e..20e74394145485 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdownControl.ts @@ -5,26 +5,27 @@ * 2.0. */ -import { uniq } from 'lodash'; -import { Datatable, Render, ExpressionFunctionDefinition } from '../../../types'; +import { uniqBy } from 'lodash'; +import { Datatable, ExpressionValueRender, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { filterColumn: string; + labelColumn: string; valueColumn: string; filterGroup: string; } interface Return { column: string; - choices: any; + choices: Array<[string, string]>; } export function dropdownControl(): ExpressionFunctionDefinition< 'dropdownControl', Datatable, Arguments, - Render + ExpressionValueRender > { const { help, args: argHelp } = getFunctionHelp().dropdownControl; @@ -40,6 +41,11 @@ export function dropdownControl(): ExpressionFunctionDefinition< required: true, help: argHelp.filterColumn, }, + labelColumn: { + types: ['string'], + required: false, + help: argHelp.labelColumn, + }, valueColumn: { types: ['string'], required: true, @@ -50,15 +56,18 @@ export function dropdownControl(): ExpressionFunctionDefinition< help: argHelp.filterGroup, }, }, - fn: (input, { valueColumn, filterColumn, filterGroup }) => { - let choices = []; + fn: (input, { valueColumn, filterColumn, filterGroup, labelColumn }) => { + let choices: Array<[string, string]> = []; + const labelCol = labelColumn || valueColumn; const filteredRows = input.rows.filter( (row) => row[valueColumn] !== null && row[valueColumn] !== undefined ); if (filteredRows.length > 0) { - choices = uniq(filteredRows.map((row) => row[valueColumn])).sort(); + choices = filteredRows.map((row) => [row[valueColumn], row[labelCol]]); + + choices = uniqBy(choices, (choice) => choice[0]); } const column = filterColumn || valueColumn; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts similarity index 73% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.js rename to x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts index 54fa79e3f60e6c..d8f2e8518daf08 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/dropdown_control.test.ts @@ -5,16 +5,13 @@ * 2.0. */ +// @ts-expect-error untyped local import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { testTable } from './__fixtures__/test_tables'; +import { testTable, relationalTable } from './__fixtures__/test_tables'; import { dropdownControl } from './dropdownControl'; describe('dropdownControl', () => { const fn = functionWrapper(dropdownControl); - const uniqueNames = testTable.rows.reduce( - (unique, { name }) => (unique.includes(name) ? unique : unique.concat([name])), - [] - ); it('returns a render as dropdown_filter', () => { expect(fn(testTable, { filterColumn: 'name', valueColumn: 'name' })).toHaveProperty( @@ -30,6 +27,11 @@ describe('dropdownControl', () => { describe('args', () => { describe('valueColumn', () => { it('populates dropdown choices with unique values in valueColumn', () => { + const uniqueNames = testTable.rows.reduce>( + (unique, { name }) => + unique.find(([value, label]) => value === name) ? unique : [...unique, [name, name]], + [] + ); expect(fn(testTable, { valueColumn: 'name' }).value.choices).toEqual(uniqueNames); }); @@ -38,6 +40,15 @@ describe('dropdownControl', () => { expect(fn(testTable, { valueColumn: '' }).value.choices).toEqual([]); }); }); + + describe('labelColumn', () => { + it('populates dropdown choices with labels from label column', () => { + const expectedChoices = relationalTable.rows.map((row) => [row.id, row.name]); + expect( + fn(relationalTable, { valueColumn: 'id', labelColumn: 'name' }).value.choices + ).toEqual(expectedChoices); + }); + }); }); describe('filterColumn', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot index 286c55994f27ec..b5c130bea36913 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot @@ -40,19 +40,19 @@ exports[`Storyshots renderers/DropdownFilter with choices 1`] = ` @@ -82,19 +82,19 @@ exports[`Storyshots renderers/DropdownFilter with choices and new value 1`] = ` @@ -124,19 +124,19 @@ exports[`Storyshots renderers/DropdownFilter with choices and value 1`] = ` diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/dropdown_filter.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/dropdown_filter.stories.tsx index 16ad90def83bc5..b25f5fddf556c9 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/dropdown_filter.stories.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/dropdown_filter.stories.tsx @@ -10,7 +10,11 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { DropdownFilter } from '../dropdown_filter'; -const choices = ['Item One', 'Item Two', 'Item Three']; +const choices: Array<[string, string]> = [ + ['1', 'Item One'], + ['2', 'Item Two'], + ['3', 'Item Three'], +]; storiesOf('renderers/DropdownFilter', module) .add('default', () => ) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/dropdown_filter.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/dropdown_filter.tsx index 395384ddab5a98..86517c897f02dc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/dropdown_filter.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/dropdown_filter.tsx @@ -17,7 +17,7 @@ export interface Props { * A collection of choices to display in the dropdown * @default [] */ - choices?: string[]; + choices?: Array<[string, string]>; /** * Optional value for the component. If the value is not present in the * choices collection, it will be discarded. @@ -38,7 +38,7 @@ export const DropdownFilter: FunctionComponent = ({ let options = [ { value: '%%CANVAS_MATCH_ALL%%', text: `-- ${strings.getMatchAllOptionLabel()} --` }, ]; - options = options.concat(choices.map((choice) => ({ value: choice, text: choice }))); + options = options.concat(choices.map((choice) => ({ value: choice[0], text: choice[1] }))); const changeHandler = (e: FocusEvent | ChangeEvent) => { if (e && e.target) { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts b/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts index 70662b16389d04..28817e6542547e 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/dropdown_control.ts @@ -21,6 +21,9 @@ export const help: FunctionHelp> = { defaultMessage: 'The column or field that you want to filter.', } ), + labelColumn: i18n.translate('xpack.canvas.functions.dropdownControl.args.labelColumnHelpText', { + defaultMessage: 'The column or field to use as the label in the dropdown control', + }), valueColumn: i18n.translate('xpack.canvas.functions.dropdownControl.args.valueColumnHelpText', { defaultMessage: 'The column or field from which to extract the unique values for the dropdown control.', From a42eab1dff572feb05a9c0764609d4b7a593fdb8 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 11 Feb 2021 19:52:26 +0100 Subject: [PATCH 16/26] [Search Sessions] batch trackId calls (#90956) --- .../search/session/session_service.test.ts | 70 +++++++++++++++++++ .../server/search/session/session_service.ts | 62 +++++++++++++++- .../api_integration/apis/search/session.ts | 66 +++++++++-------- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index 24d13cf24ccfb5..b195a32ad481f2 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -317,6 +317,76 @@ describe('SearchSessionService', () => { expect(savedObjectsClient.update).toHaveBeenCalledTimes(MAX_UPDATE_RETRIES); expect(savedObjectsClient.create).toHaveBeenCalledTimes(MAX_UPDATE_RETRIES); }); + + it('batches updates for the same session', async () => { + const sessionId1 = 'sessiondId1'; + const sessionId2 = 'sessiondId2'; + + const searchRequest1 = { params: { 1: '1' } }; + const requestHash1 = createRequestHash(searchRequest1.params); + const searchId1 = 'searchId1'; + + const searchRequest2 = { params: { 2: '2' } }; + const requestHash2 = createRequestHash(searchRequest2.params); + const searchId2 = 'searchId1'; + + const searchRequest3 = { params: { 3: '3' } }; + const requestHash3 = createRequestHash(searchRequest3.params); + const searchId3 = 'searchId3'; + + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + await Promise.all([ + service.trackId({ savedObjectsClient }, searchRequest1, searchId1, { + sessionId: sessionId1, + strategy: MOCK_STRATEGY, + }), + service.trackId({ savedObjectsClient }, searchRequest2, searchId2, { + sessionId: sessionId1, + strategy: MOCK_STRATEGY, + }), + service.trackId({ savedObjectsClient }, searchRequest3, searchId3, { + sessionId: sessionId2, + strategy: MOCK_STRATEGY, + }), + ]); + + expect(savedObjectsClient.update).toHaveBeenCalledTimes(2); // 3 trackIds calls batched into 2 update calls (2 different sessions) + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + + const [type1, id1, callAttributes1] = savedObjectsClient.update.mock.calls[0]; + expect(type1).toBe(SEARCH_SESSION_TYPE); + expect(id1).toBe(sessionId1); + expect(callAttributes1).toHaveProperty('idMapping', { + [requestHash1]: { + id: searchId1, + status: SearchSessionStatus.IN_PROGRESS, + strategy: MOCK_STRATEGY, + }, + [requestHash2]: { + id: searchId2, + status: SearchSessionStatus.IN_PROGRESS, + strategy: MOCK_STRATEGY, + }, + }); + expect(callAttributes1).toHaveProperty('touched'); + + const [type2, id2, callAttributes2] = savedObjectsClient.update.mock.calls[1]; + expect(type2).toBe(SEARCH_SESSION_TYPE); + expect(id2).toBe(sessionId2); + expect(callAttributes2).toHaveProperty('idMapping', { + [requestHash3]: { + id: searchId3, + status: SearchSessionStatus.IN_PROGRESS, + strategy: MOCK_STRATEGY, + }, + }); + expect(callAttributes2).toHaveProperty('touched'); + }); }); describe('getId', () => { diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 2d0e7e519e3bd4..6a36b1b4859ed3 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { debounce } from 'lodash'; import { CoreSetup, CoreStart, @@ -43,12 +44,24 @@ interface StartDependencies { taskManager: TaskManagerStartContract; } +const DEBOUNCE_UPDATE_OR_CREATE_WAIT = 1000; +const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000; + +interface UpdateOrCreateQueueEntry { + deps: SearchSessionDependencies; + sessionId: string; + attributes: Partial; + resolve: () => void; + reject: (reason?: unknown) => void; +} + function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } export class SearchSessionService implements ISearchSessionService { private sessionConfig: SearchSessionsConfig; + private readonly updateOrCreateBatchQueue: UpdateOrCreateQueueEntry[] = []; constructor(private readonly logger: Logger, private readonly config: ConfigSchema) { this.sessionConfig = this.config.search.sessions; @@ -78,6 +91,53 @@ export class SearchSessionService } }; + private processUpdateOrCreateBatchQueue = debounce( + () => { + const queue = [...this.updateOrCreateBatchQueue]; + if (queue.length === 0) return; + this.updateOrCreateBatchQueue.length = 0; + const batchedSessionAttributes = queue.reduce((res, next) => { + if (!res[next.sessionId]) { + res[next.sessionId] = next.attributes; + } else { + res[next.sessionId] = { + ...res[next.sessionId], + ...next.attributes, + idMapping: { + ...res[next.sessionId].idMapping, + ...next.attributes.idMapping, + }, + }; + } + return res; + }, {} as { [sessionId: string]: Partial }); + + Object.keys(batchedSessionAttributes).forEach((sessionId) => { + const thisSession = queue.filter((s) => s.sessionId === sessionId); + this.updateOrCreate(thisSession[0].deps, sessionId, batchedSessionAttributes[sessionId]) + .then(() => { + thisSession.forEach((s) => s.resolve()); + }) + .catch((e) => { + thisSession.forEach((s) => s.reject(e)); + }); + }); + }, + DEBOUNCE_UPDATE_OR_CREATE_WAIT, + { maxWait: DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT } + ); + private scheduleUpdateOrCreate = ( + deps: SearchSessionDependencies, + sessionId: string, + attributes: Partial + ): Promise => { + return new Promise((resolve, reject) => { + this.updateOrCreateBatchQueue.push({ deps, sessionId, attributes, resolve, reject }); + // TODO: this would be better if we'd debounce per sessionId + this.processUpdateOrCreateBatchQueue(); + }); + }; + private updateOrCreate = async ( deps: SearchSessionDependencies, sessionId: string, @@ -255,7 +315,7 @@ export class SearchSessionService idMapping = { [requestHash]: searchInfo }; } - await this.updateOrCreate(deps, sessionId, { idMapping }); + await this.scheduleUpdateOrCreate(deps, sessionId, { idMapping }); }; public async getSearchIdMapping(deps: SearchSessionDependencies, sessionId: string) { diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 984f3e3f7dd4e6..e7834ed3d86419 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -11,6 +11,7 @@ import { SearchSessionStatus } from '../../../../plugins/data_enhanced/common'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const retry = getService('retry'); describe('search session', () => { describe('session management', () => { @@ -152,20 +153,23 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - const resp = await supertest - .get(`/internal/session/${sessionId}`) - .set('kbn-xsrf', 'foo') - .expect(200); - - const { name, touched, created, persisted, idMapping } = resp.body.attributes; - expect(persisted).to.be(true); - expect(name).to.be('My Session'); - expect(touched).not.to.be(undefined); - expect(created).not.to.be(undefined); - - const idMappings = Object.values(idMapping).map((value: any) => value.id); - expect(idMappings).to.contain(id1); - expect(idMappings).to.contain(id2); + await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + const resp = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + + const { name, touched, created, persisted, idMapping } = resp.body.attributes; + expect(persisted).to.be(true); + expect(name).to.be('My Session'); + expect(touched).not.to.be(undefined); + expect(created).not.to.be(undefined); + + const idMappings = Object.values(idMapping).map((value: any) => value.id); + expect(idMappings).to.contain(id1); + expect(idMappings).to.contain(id2); + return true; + }); }); it('should create and extend a session', async () => { @@ -245,21 +249,24 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - const resp = await supertest - .get(`/internal/session/${sessionId}`) - .set('kbn-xsrf', 'foo') - .expect(200); + await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + const resp = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .expect(200); - const { appId, name, touched, created, persisted, idMapping } = resp.body.attributes; - expect(persisted).to.be(false); - expect(name).to.be(undefined); - expect(appId).to.be(undefined); - expect(touched).not.to.be(undefined); - expect(created).not.to.be(undefined); + const { appId, name, touched, created, persisted, idMapping } = resp.body.attributes; + expect(persisted).to.be(false); + expect(name).to.be(undefined); + expect(appId).to.be(undefined); + expect(touched).not.to.be(undefined); + expect(created).not.to.be(undefined); - const idMappings = Object.values(idMapping).map((value: any) => value.id); - expect(idMappings).to.contain(id1); - expect(idMappings).to.contain(id2); + const idMappings = Object.values(idMapping).map((value: any) => value.id); + expect(idMappings).to.contain(id1); + expect(idMappings).to.contain(id2); + return true; + }); }); it('touched time updates when you poll on an search', async () => { @@ -287,7 +294,7 @@ export default function ({ getService }: FtrProviderContext) { const { id: id1 } = searchRes1.body; // it might take the session a moment to be created - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 2500)); const getSessionFirstTime = await supertest .get(`/internal/session/${sessionId}`) @@ -303,6 +310,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); + // it might take the session a moment to be updated + await new Promise((resolve) => setTimeout(resolve, 2500)); + const getSessionSecondTime = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') From c22366e69dee3fd13ef6bee77d6aa427d89b087f Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 11 Feb 2021 13:55:45 -0500 Subject: [PATCH 17/26] [Fleet] Remove aliases from index_template when updating an existing template (#91142) --- .../elasticsearch/template/install.test.ts | 76 ++++++++++++++++++- .../epm/elasticsearch/template/install.ts | 39 ++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts index be9213aff360d0..d2eb111b790607 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts @@ -13,6 +13,12 @@ import { installTemplate } from './install'; test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + callCluster.mockImplementation((_, params) => { + if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { + return { index_templates: [] }; + } + }); + const fields: Field[] = []; const dataStreamDatasetIsPrefixUnset = { type: 'metrics', @@ -37,7 +43,7 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[0][1].body; + const sentTemplate = callCluster.mock.calls[1][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); @@ -45,6 +51,12 @@ test('tests installPackage to use correct priority and index_patterns for data s test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + callCluster.mockImplementation((_, params) => { + if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { + return { index_templates: [] }; + } + }); + const fields: Field[] = []; const dataStreamDatasetIsPrefixFalse = { type: 'metrics', @@ -70,7 +82,7 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[0][1].body; + const sentTemplate = callCluster.mock.calls[1][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixFalse); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); @@ -78,6 +90,12 @@ test('tests installPackage to use correct priority and index_patterns for data s test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + callCluster.mockImplementation((_, params) => { + if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { + return { index_templates: [] }; + } + }); + const fields: Field[] = []; const dataStreamDatasetIsPrefixTrue = { type: 'metrics', @@ -103,8 +121,60 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[0][1].body; + const sentTemplate = callCluster.mock.calls[1][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixTrue); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); }); + +test('tests installPackage remove the aliases property if the property existed', async () => { + const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; + callCluster.mockImplementation((_, params) => { + if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { + return { + index_templates: [ + { + name: 'metrics-package.dataset', + index_template: { + index_patterns: ['metrics-package.dataset-*'], + template: { aliases: {} }, + }, + }, + ], + }; + } + }); + + const fields: Field[] = []; + const dataStreamDatasetIsPrefixUnset = { + type: 'metrics', + dataset: 'package.dataset', + title: 'test data stream', + release: 'experimental', + package: 'package', + path: 'path', + ingest_pipeline: 'default', + } as RegistryDataStream; + const pkg = { + name: 'package', + version: '0.0.1', + }; + const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; + const templatePriorityDatasetIsPrefixUnset = 200; + await installTemplate({ + callCluster, + fields, + dataStream: dataStreamDatasetIsPrefixUnset, + packageVersion: pkg.version, + packageName: pkg.name, + }); + + // @ts-ignore + const removeAliases = callCluster.mock.calls[1][1].body; + expect(removeAliases.template.aliases).not.toBeDefined(); + // @ts-ignore + const sentTemplate = callCluster.mock.calls[2][1].body; + expect(sentTemplate).toBeDefined(); + expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); + expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index f5f1b4bea788d4..70afa78e723bcf 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -311,6 +311,45 @@ export async function installTemplate({ }); } + // Datastream now throw an error if the aliases field is present so ensure that we remove that field. + const getTemplateRes = await callCluster('transport.request', { + method: 'GET', + path: `/_index_template/${templateName}`, + ignore: [404], + }); + + const existingIndexTemplate = getTemplateRes?.index_templates?.[0]; + if ( + existingIndexTemplate && + existingIndexTemplate.name === templateName && + existingIndexTemplate?.index_template?.template?.aliases + ) { + const updateIndexTemplateParams: { + method: string; + path: string; + ignore: number[]; + body: any; + } = { + method: 'PUT', + path: `/_index_template/${templateName}`, + ignore: [404], + body: { + ...existingIndexTemplate.index_template, + template: { + ...existingIndexTemplate.index_template.template, + // Remove the aliases field + aliases: undefined, + }, + }, + }; + // This uses the catch-all endpoint 'transport.request' because there is no + // convenience endpoint using the new _index_template API yet. + // The existing convenience endpoint `indices.putTemplate` only sends to _template, + // which does not support v2 templates. + // See src/core/server/elasticsearch/api_types.ts for available endpoints. + await callCluster('transport.request', updateIndexTemplateParams); + } + const composedOfTemplates = await installDataStreamComponentTemplates( templateName, dataStream.elasticsearch, From e76b66c43d8fbaf26be7ef580353e21811f57d35 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 11 Feb 2021 11:15:38 -0800 Subject: [PATCH 18/26] [Core][SO] - Updating SO _find filter parser to take into consideration multi-fields (#90988) This PR addresses the bug #90985 . Please see link for bug details. TLDR: SO _find filter does not take into consideration that filter string can refer to multi-fields which should be parsed differently. This addition adds to the helper method that checks if there are any errors in the filter formatting. --- .../service/lib/filter_utils.test.ts | 124 +++++++++++++++++- .../saved_objects/service/lib/filter_utils.ts | 22 +++- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index f6f8f88e84304e..05a936db4bfee5 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -9,7 +9,12 @@ // @ts-expect-error no ts import { esKuery } from '../../es_query'; -import { validateFilterKueryNode, validateConvertFilterToKueryNode } from './filter_utils'; +import { + validateFilterKueryNode, + validateConvertFilterToKueryNode, + fieldDefined, + hasFilterKeyError, +} from './filter_utils'; const mockMappings = { properties: { @@ -39,6 +44,18 @@ const mockMappings = { }, }, }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, alert: { properties: { actions: { @@ -90,6 +107,15 @@ describe('Filter Utils', () => { validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); }); + test('Validate a multi-field KQL expression filter', () => { + expect( + validateConvertFilterToKueryNode( + ['bean'], + 'bean.attributes.canned.text: "best"', + mockMappings + ) + ).toEqual(esKuery.fromKueryExpression('bean.canned.text: "best"')); + }); test('Assemble filter kuery node saved object attributes with one saved object type', () => { expect( validateConvertFilterToKueryNode( @@ -485,4 +511,100 @@ describe('Filter Utils', () => { ]); }); }); + + describe('#hasFilterKeyError', () => { + test('Return no error if filter key is valid', () => { + const hasError = hasFilterKeyError('bean.attributes.canned.text', ['bean'], mockMappings); + + expect(hasError).toBeNull(); + }); + + test('Return error if key is not defined', () => { + const hasError = hasFilterKeyError(undefined, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key is null', () => { + const hasError = hasFilterKeyError(null, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key does not identify an SO wrapper', () => { + const hasError = hasFilterKeyError('beanattributescannedtext', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'beanattributescannedtext' need to be wrapped by a saved object type like bean" + ); + }); + + test('Return error if key does not match an SO type', () => { + const hasError = hasFilterKeyError('canned.attributes.bean.text', ['bean'], mockMappings); + + expect(hasError).toEqual('This type canned is not allowed'); + }); + + test('Return error if key does not match SO attribute structure', () => { + const hasError = hasFilterKeyError('bean.canned.text', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.canned.text' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key matches SO attribute parent, not attribute itself', () => { + const hasError = hasFilterKeyError('alert.actions', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.actions' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key refers to a non-existent attribute parent', () => { + const hasError = hasFilterKeyError('alert.not_a_key', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.not_a_key' does NOT exist in alert saved object index patterns" + ); + }); + + test('Return error if key refers to a non-existent attribute', () => { + const hasError = hasFilterKeyError('bean.attributes.red', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.attributes.red' does NOT exist in bean saved object index patterns" + ); + }); + }); + + describe('#fieldDefined', () => { + test('Return false if filter is using an non-existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.not_a_key'); + + expect(isFieldDefined).toBeFalsy(); + }); + + test('Return true if filter is using an existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.title'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a non-default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned.text'); + + expect(isFieldDefined).toBeTruthy(); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index b81c7d3e0885a4..54b0033c9fcbe3 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -205,7 +205,25 @@ export const hasFilterKeyError = ( return null; }; -const fieldDefined = (indexMappings: IndexMapping, key: string) => { +export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean => { const mappingKey = 'properties.' + key.split('.').join('.properties.'); - return get(indexMappings, mappingKey) != null; + const potentialKey = get(indexMappings, mappingKey); + + // If the `mappingKey` does not match a valid path, before returning null, + // we want to check and see if the intended path was for a multi-field + // such as `x.attributes.field.text` where `field` is mapped to both text + // and keyword + if (potentialKey == null) { + const propertiesAttribute = 'properties'; + const indexOfLastProperties = mappingKey.lastIndexOf(propertiesAttribute); + const fieldMapping = mappingKey.substr(0, indexOfLastProperties); + const fieldType = mappingKey.substr( + mappingKey.lastIndexOf(propertiesAttribute) + `${propertiesAttribute}.`.length + ); + const mapping = `${fieldMapping}fields.${fieldType}`; + + return get(indexMappings, mapping) != null; + } else { + return true; + } }; From c5b5f20baf440e08e6c72928d637816d5687af1c Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 11 Feb 2021 13:17:03 -0600 Subject: [PATCH 19/26] Revert "[Fleet] Remove aliases from index_template when updating an existing template (#91142)" This reverts commit c22366e69dee3fd13ef6bee77d6aa427d89b087f. --- .../elasticsearch/template/install.test.ts | 76 +------------------ .../epm/elasticsearch/template/install.ts | 39 ---------- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts index d2eb111b790607..be9213aff360d0 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts @@ -13,12 +13,6 @@ import { installTemplate } from './install'; test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; - callCluster.mockImplementation((_, params) => { - if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { - return { index_templates: [] }; - } - }); - const fields: Field[] = []; const dataStreamDatasetIsPrefixUnset = { type: 'metrics', @@ -43,7 +37,7 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[1][1].body; + const sentTemplate = callCluster.mock.calls[0][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); @@ -51,12 +45,6 @@ test('tests installPackage to use correct priority and index_patterns for data s test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; - callCluster.mockImplementation((_, params) => { - if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { - return { index_templates: [] }; - } - }); - const fields: Field[] = []; const dataStreamDatasetIsPrefixFalse = { type: 'metrics', @@ -82,7 +70,7 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[1][1].body; + const sentTemplate = callCluster.mock.calls[0][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixFalse); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); @@ -90,12 +78,6 @@ test('tests installPackage to use correct priority and index_patterns for data s test('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; - callCluster.mockImplementation((_, params) => { - if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { - return { index_templates: [] }; - } - }); - const fields: Field[] = []; const dataStreamDatasetIsPrefixTrue = { type: 'metrics', @@ -121,60 +103,8 @@ test('tests installPackage to use correct priority and index_patterns for data s packageName: pkg.name, }); // @ts-ignore - const sentTemplate = callCluster.mock.calls[1][1].body; + const sentTemplate = callCluster.mock.calls[0][1].body; expect(sentTemplate).toBeDefined(); expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixTrue); expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); }); - -test('tests installPackage remove the aliases property if the property existed', async () => { - const callCluster = elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser; - callCluster.mockImplementation((_, params) => { - if (params.method === 'GET' && params.path === '/_index_template/metrics-package.dataset') { - return { - index_templates: [ - { - name: 'metrics-package.dataset', - index_template: { - index_patterns: ['metrics-package.dataset-*'], - template: { aliases: {} }, - }, - }, - ], - }; - } - }); - - const fields: Field[] = []; - const dataStreamDatasetIsPrefixUnset = { - type: 'metrics', - dataset: 'package.dataset', - title: 'test data stream', - release: 'experimental', - package: 'package', - path: 'path', - ingest_pipeline: 'default', - } as RegistryDataStream; - const pkg = { - name: 'package', - version: '0.0.1', - }; - const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; - const templatePriorityDatasetIsPrefixUnset = 200; - await installTemplate({ - callCluster, - fields, - dataStream: dataStreamDatasetIsPrefixUnset, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - // @ts-ignore - const removeAliases = callCluster.mock.calls[1][1].body; - expect(removeAliases.template.aliases).not.toBeDefined(); - // @ts-ignore - const sentTemplate = callCluster.mock.calls[2][1].body; - expect(sentTemplate).toBeDefined(); - expect(sentTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); - expect(sentTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); -}); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 70afa78e723bcf..f5f1b4bea788d4 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -311,45 +311,6 @@ export async function installTemplate({ }); } - // Datastream now throw an error if the aliases field is present so ensure that we remove that field. - const getTemplateRes = await callCluster('transport.request', { - method: 'GET', - path: `/_index_template/${templateName}`, - ignore: [404], - }); - - const existingIndexTemplate = getTemplateRes?.index_templates?.[0]; - if ( - existingIndexTemplate && - existingIndexTemplate.name === templateName && - existingIndexTemplate?.index_template?.template?.aliases - ) { - const updateIndexTemplateParams: { - method: string; - path: string; - ignore: number[]; - body: any; - } = { - method: 'PUT', - path: `/_index_template/${templateName}`, - ignore: [404], - body: { - ...existingIndexTemplate.index_template, - template: { - ...existingIndexTemplate.index_template.template, - // Remove the aliases field - aliases: undefined, - }, - }, - }; - // This uses the catch-all endpoint 'transport.request' because there is no - // convenience endpoint using the new _index_template API yet. - // The existing convenience endpoint `indices.putTemplate` only sends to _template, - // which does not support v2 templates. - // See src/core/server/elasticsearch/api_types.ts for available endpoints. - await callCluster('transport.request', updateIndexTemplateParams); - } - const composedOfTemplates = await installDataStreamComponentTemplates( templateName, dataStream.elasticsearch, From 3e234d074fa27b4ed54b4cf3360bf5d04f175a7f Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 11 Feb 2021 14:19:02 -0500 Subject: [PATCH 20/26] [Uptime] Format `PingList` duration time as seconds when appropriate (#90703) * Introduce new formatting logic for ping list, duration strings now converted to seconds when appropriate. * Handle singular plurality case. * Make limit for conversion 10 sec instead of 1 sec. * Switch conversion threshold back to one second, add tests. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monitor/ping_list/ping_list.test.tsx | 20 ++++++++++- .../monitor/ping_list/ping_list.tsx | 35 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx index b96b61d8743300..bf5b0215e7d7ab 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { PingList } from './ping_list'; +import { formatDuration, PingList } from './ping_list'; import { Ping, PingsResponse } from '../../../../common/runtime_types'; import { ExpandedRowMap } from '../../overview/monitor_list/types'; import { rowShouldExpand, toggleDetails } from './columns/expand_row'; @@ -185,5 +185,23 @@ describe('PingList component', () => { expect(rowShouldExpand(ping)).toBe(true); }); }); + + describe('formatDuration', () => { + it('returns zero for < 1 millisecond', () => { + expect(formatDuration(984)).toBe('0 ms'); + }); + + it('returns milliseconds string if < 1 seconds', () => { + expect(formatDuration(921_039)).toBe('921 ms'); + }); + + it('returns seconds string if > 1 second', () => { + expect(formatDuration(1_032_100)).toBe('1 second'); + }); + + it('rounds to closest second', () => { + expect(formatDuration(1_832_100)).toBe('2 seconds'); + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 110c46eca31d18..18bc5f5ec3ecbf 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -34,6 +34,35 @@ export const SpanWithMargin = styled.span` const DEFAULT_PAGE_SIZE = 10; +// one second = 1 million micros +const ONE_SECOND_AS_MICROS = 1000000; + +// the limit for converting to seconds is >= 1 sec +const MILLIS_LIMIT = ONE_SECOND_AS_MICROS * 1; + +export const formatDuration = (durationMicros: number) => { + if (durationMicros < MILLIS_LIMIT) { + return i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis} ms', + }); + } + const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + + // we format seconds with correct pulralization here and not for `ms` because it is much more likely users + // will encounter times of exactly '1' second. + if (seconds === '1') { + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting.singular', { + values: { seconds }, + defaultMessage: '{seconds} second', + }); + } + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting', { + values: { seconds }, + defaultMessage: '{seconds} seconds', + }); +}; + export const PingList = () => { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageIndex, setPageIndex] = useState(0); @@ -135,11 +164,7 @@ export const PingList = () => { name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { defaultMessage: 'Duration', }), - render: (duration: number) => - i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { - values: { millis: microsToMillis(duration) }, - defaultMessage: '{millis} ms', - }), + render: (duration: number) => formatDuration(duration), }, { field: 'error.type', From 13740f1cd36ccbdeb850361c840e390929ac046c Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Thu, 11 Feb 2021 13:45:18 -0600 Subject: [PATCH 21/26] [ML] Add Create Data Frame Analytics card to Data Visualizer (#91011) --- .../ml/common/constants/ml_url_generator.ts | 1 + .../ml/common/types/ml_url_generator.ts | 1 + .../data_recognizer/recognized_result.js | 4 +- .../index.ts | 2 +- .../link_card.tsx} | 2 +- .../actions_panel/actions_panel.tsx | 176 ++++++++++-------- .../jobs/new_job/pages/job_type/page.tsx | 6 +- .../ml_url_generator/ml_url_generator.ts | 1 + .../index_data_visualizer_actions_panel.ts | 1 + .../apps/ml/permissions/full_ml_access.ts | 1 + .../apps/ml/permissions/read_ml_access.ts | 1 + .../ml/data_visualizer_index_based.ts | 8 + .../index_data_visualizer_actions_panel.ts | 3 +- .../apps/ml/permissions/full_ml_access.ts | 1 + .../apps/ml/permissions/read_ml_access.ts | 1 + 15 files changed, 128 insertions(+), 81 deletions(-) rename x-pack/plugins/ml/public/application/components/{create_job_link_card => link_card}/index.ts (80%) rename x-pack/plugins/ml/public/application/components/{create_job_link_card/create_job_link_card.tsx => link_card/link_card.tsx} (97%) diff --git a/x-pack/plugins/ml/common/constants/ml_url_generator.ts b/x-pack/plugins/ml/common/constants/ml_url_generator.ts index ab2116df3e7cbf..bb0684309201cf 100644 --- a/x-pack/plugins/ml/common/constants/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/constants/ml_url_generator.ts @@ -36,6 +36,7 @@ export const ML_PAGES = { */ DATA_VISUALIZER_INDEX_VIEWER: 'jobs/new_job/datavisualizer', ANOMALY_DETECTION_CREATE_JOB: `jobs/new_job`, + ANOMALY_DETECTION_CREATE_JOB_ADVANCED: `jobs/new_job/advanced`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, SETTINGS: 'settings', diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 216d4571804e9b..766b714abcc986 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -64,6 +64,7 @@ export interface DataVisualizerFileBasedAppState extends Omit { @@ -34,7 +34,7 @@ export const RecognizedResult = ({ config, indexPattern, savedSearch }) => { return ( - = ({ +export const LinkCard: FC = ({ icon, iconAreaLabel, title, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 255dfcc21ccab8..850367fc1a65a0 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -9,24 +9,15 @@ import React, { FC, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiSpacer, - EuiText, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiCard, - EuiIcon, -} from '@elastic/eui'; -import { Link } from 'react-router-dom'; -import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; +import { EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; +import { LinkCard } from '../../../../components/link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState, } from '../../../../../../../../../src/plugins/discover/public'; -import { useMlKibana } from '../../../../contexts/kibana'; +import { useMlKibana, useMlLink } from '../../../../contexts/kibana'; import { isFullLicense } from '../../../../license'; import { checkPermission } from '../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../ml_nodes_check'; @@ -57,12 +48,18 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer setRecognizerResultsCount(recognizerResults.count); }, }; - const showCreateJob = - isFullLicense() && - checkPermission('canCreateJob') && - mlNodesAvailable() && - indexPattern.timeFieldName !== undefined; - const createJobLink = `/${ML_PAGES.ANOMALY_DETECTION_CREATE_JOB}/advanced?index=${indexPattern.id}`; + const mlAvailable = isFullLicense() && checkPermission('canCreateJob') && mlNodesAvailable(); + const showCreateAnomalyDetectionJob = mlAvailable && indexPattern.timeFieldName !== undefined; + + const createJobLink = useMlLink({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED, + pageState: { index: indexPattern.id }, + }); + + const createDataFrameAnalyticsLink = useMlLink({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, + pageState: { index: indexPattern.id }, + }); useEffect(() => { let unmounted = false; @@ -95,6 +92,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer setDiscoverLink(discoverUrl); } }; + getDiscoverUrl(); return () => { unmounted = true; @@ -106,7 +104,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer // controls whether the recognizer section is ultimately displayed. return (
- {showCreateJob && ( + {mlAvailable && ( <>

@@ -117,50 +115,84 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer

- + + + )} + + )} + {mlAvailable && indexPattern.id !== undefined && createDataFrameAnalyticsLink && ( + <>

- - - + + } + data-test-subj="mlDataVisualizerCreateDataFrameAnalyticsCard" + /> )} @@ -176,25 +208,23 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer - - } - description={i18n.translate( - 'xpack.ml.datavisualizer.actionsPanel.viewIndexInDiscoverDescription', - { - defaultMessage: 'Explore index in Discover', - } - )} - title={ - + - + )} + title={ + + } + data-test-subj="mlDataVisualizerViewInDiscoverCard" + /> )}
diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index e879256d53c76c..782a23be87dec9 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -26,7 +26,7 @@ import { isSavedSearchSavedObject } from '../../../../../../common/types/kibana' import { DataRecognizer } from '../../../../components/data_recognizer'; import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; import { timeBasedIndexCheck } from '../../../../util/index_utils'; -import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; +import { LinkCard } from '../../../../components/link_card'; import { CategorizationIcon } from './categorization_job_icon'; import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; @@ -257,7 +257,7 @@ export const Page: FC = () => { {jobTypes.map(({ onClick, icon, title, description, id }) => ( - { - { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index 69ae3961dfd4dd..00cda88e0dc58c 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -359,6 +359,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts index d8ec8ed49f011b..53b87042d48da4 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts @@ -153,6 +153,14 @@ export function MachineLearningDataVisualizerIndexBasedProvider({ await testSubjects.clickWhenNotDisabled('mlDataVisualizerCreateAdvancedJobCard'); }, + async assertCreateDataFrameAnalyticsCardExists() { + await testSubjects.existOrFail('mlDataVisualizerCreateDataFrameAnalyticsCard'); + }, + + async assertCreateDataFrameAnalyticsCardNotExists() { + await testSubjects.missingOrFail('mlDataVisualizerCreateDataFrameAnalyticsCard'); + }, + async assertViewInDiscoverCardExists() { await testSubjects.existOrFail('mlDataVisualizerViewInDiscoverCard'); }, diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index 8a59d6ed3ce2a1..642cc60e904419 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -41,8 +41,9 @@ export default function ({ getService }: FtrProviderContext) { }); it('navigates to Discover page', async () => { - await ml.testExecution.logTestStep('should not display create job card'); + await ml.testExecution.logTestStep('should not display create job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); await ml.testExecution.logTestStep('displays the actions panel with view in Discover card'); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index b09270b1d0f780..9806c186914a33 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -134,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index 14cc4e93b37ab7..632922a353b335 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -134,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { From 7fc3d125bf569636cd4cc45ac9b73ffb8c8733e3 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Thu, 11 Feb 2021 12:58:08 -0700 Subject: [PATCH 22/26] Support `pit` and `search_after` in server `savedObjects.find` (#89915) --- ...kibana-plugin-core-public.doclinksstart.md | 3 +- ...gin-core-public.savedobjectsfindoptions.md | 2 + ...core-public.savedobjectsfindoptions.pit.md | 13 + ...lic.savedobjectsfindoptions.searchafter.md | 13 + .../core/server/kibana-plugin-core-server.md | 5 + ...ver.savedobjectsclient.closepointintime.md | 25 + ...a-plugin-core-server.savedobjectsclient.md | 2 + ...vedobjectsclient.openpointintimefortype.md | 25 + ...ver.savedobjectsclosepointintimeoptions.md | 12 + ...er.savedobjectsclosepointintimeresponse.md | 20 + ...jectsclosepointintimeresponse.num_freed.md | 13 + ...jectsclosepointintimeresponse.succeeded.md | 13 + ...rver.savedobjectsexporter._constructor_.md | 5 +- ...plugin-core-server.savedobjectsexporter.md | 2 +- ...gin-core-server.savedobjectsfindoptions.md | 2 + ...core-server.savedobjectsfindoptions.pit.md | 13 + ...ver.savedobjectsfindoptions.searchafter.md | 13 + ...in-core-server.savedobjectsfindresponse.md | 1 + ...-server.savedobjectsfindresponse.pit_id.md | 11 + ...ugin-core-server.savedobjectsfindresult.md | 1 + ...core-server.savedobjectsfindresult.sort.md | 41 ++ ...objectsopenpointintimeoptions.keepalive.md | 13 + ...rver.savedobjectsopenpointintimeoptions.md | 20 + ...bjectsopenpointintimeoptions.preference.md | 13 + ....savedobjectsopenpointintimeresponse.id.md | 13 + ...ver.savedobjectsopenpointintimeresponse.md | 19 + ...in-core-server.savedobjectspitparams.id.md | 11 + ...-server.savedobjectspitparams.keepalive.md | 11 + ...lugin-core-server.savedobjectspitparams.md | 20 + ...savedobjectsrepository.closepointintime.md | 58 ++ ...ugin-core-server.savedobjectsrepository.md | 2 + ...bjectsrepository.openpointintimefortype.md | 57 ++ ...-plugin-core-server.searchresponse.hits.md | 2 +- ...ibana-plugin-core-server.searchresponse.md | 3 +- ...lugin-core-server.searchresponse.pit_id.md | 11 + docs/user/security/audit-logging.asciidoc | 8 + src/core/public/public.api.md | 4 + .../saved_objects/saved_objects_client.ts | 11 +- src/core/server/elasticsearch/client/types.ts | 3 +- src/core/server/index.ts | 5 + .../export/point_in_time_finder.test.ts | 321 +++++++++++ .../export/point_in_time_finder.ts | 192 +++++++ .../export/saved_objects_exporter.test.ts | 512 +++++++++++++----- .../export/saved_objects_exporter.ts | 35 +- .../saved_objects/saved_objects_service.ts | 1 + .../service/lib/repository.mock.ts | 2 + .../service/lib/repository.test.js | 165 ++++++ .../saved_objects/service/lib/repository.ts | 138 ++++- .../service/lib/repository_es_client.ts | 2 + .../service/lib/search_dsl/pit_params.test.ts | 28 + .../service/lib/search_dsl/pit_params.ts | 18 + .../service/lib/search_dsl/search_dsl.test.ts | 35 +- .../service/lib/search_dsl/search_dsl.ts | 10 +- .../lib/search_dsl/sorting_params.test.ts | 26 + .../service/lib/search_dsl/sorting_params.ts | 12 +- .../service/saved_objects_client.mock.ts | 2 + .../service/saved_objects_client.test.js | 30 + .../service/saved_objects_client.ts | 95 ++++ src/core/server/saved_objects/types.ts | 16 + src/core/server/server.api.md | 44 +- src/core/server/types.ts | 1 + src/plugins/data/server/server.api.md | 2 +- .../apis/saved_objects/export.ts | 248 +++++---- .../apis/saved_objects/import.ts | 4 +- .../saved_objects/resolve_import_errors.ts | 6 +- test/api_integration/config.js | 1 + ...ypted_saved_objects_client_wrapper.test.ts | 62 +++ .../encrypted_saved_objects_client_wrapper.ts | 13 + .../security/server/audit/audit_events.ts | 14 + .../feature_privilege_builder/saved_object.ts | 8 +- .../privileges/privileges.test.ts | 212 ++++++++ ...ecure_saved_objects_client_wrapper.test.ts | 69 +++ .../secure_saved_objects_client_wrapper.ts | 58 ++ .../spaces_saved_objects_client.test.ts | 52 ++ .../spaces_saved_objects_client.ts | 40 ++ 75 files changed, 2724 insertions(+), 269 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md create mode 100644 src/core/server/saved_objects/export/point_in_time_finder.test.ts create mode 100644 src/core/server/saved_objects/export/point_in_time_finder.ts create mode 100644 src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts create mode 100644 src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index f206a914aef97c..dc6804b0630bd4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,4 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | - +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 8bd87c2f6ea35f..69cfb818561e5d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with . | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md new file mode 100644 index 00000000000000..2284a4d8d210d8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with . + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md new file mode 100644 index 00000000000000..99ca2c34e77be9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 5fe5eda7a81729..1791335d58fef9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -155,6 +155,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | +| [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) | | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | @@ -188,6 +189,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsMappingProperties](./kibana-plugin-core-server.savedobjectsmappingproperties.md) | Describe the fields of a [saved object type](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md). | | [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | | | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) | | +| [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) | | +| [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) | | | [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | | [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) | Options that can be specified when using the saved objects serializer to parse a raw document. | | [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | | @@ -301,6 +305,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | +| [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) | | | [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md) | Transformation function used to mutate the exported objects of the associated type.A type's export transform function will be executed once per user-initiated export, for all objects of that type. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md new file mode 100644 index 00000000000000..dc765260a08ca7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) + +## SavedObjectsClient.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index da1f4d029ea2ba..887f7f7d93a873 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -30,11 +30,13 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | +| [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md new file mode 100644 index 00000000000000..56c1d6d1ddc331 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) + +## SavedObjectsClient.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| options | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md new file mode 100644 index 00000000000000..27432a8805b06d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) + +## SavedObjectsClosePointInTimeOptions type + + +Signature: + +```typescript +export declare type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md new file mode 100644 index 00000000000000..43ecd1298d5d97 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## SavedObjectsClosePointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsClosePointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) | number | The number of search contexts that have been successfully closed. | +| [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) | boolean | If true, all search contexts associated with the PIT id are successfully closed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md new file mode 100644 index 00000000000000..b64932fcee8f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) + +## SavedObjectsClosePointInTimeResponse.num\_freed property + +The number of search contexts that have been successfully closed. + +Signature: + +```typescript +num_freed: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md new file mode 100644 index 00000000000000..225a549a4cf591 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) + +## SavedObjectsClosePointInTimeResponse.succeeded property + +If true, all search contexts associated with the PIT id are successfully closed. + +Signature: + +```typescript +succeeded: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md index 5e959bbee7beb9..3f3d708c590ee8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md @@ -9,10 +9,11 @@ Constructs a new instance of the `SavedObjectsExporter` class Signature: ```typescript -constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { +constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); ``` @@ -20,5 +21,5 @@ constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { | Parameter | Type | Description | | --- | --- | --- | -| { savedObjectsClient, typeRegistry, exportSizeLimit, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
} | | +| { savedObjectsClient, typeRegistry, exportSizeLimit, logger, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
logger: Logger;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md index 727108b824c848..ce23e91633b078 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md @@ -15,7 +15,7 @@ export declare class SavedObjectsExporter | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | +| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | ## Properties diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index d393d579dbdd24..6f7c05ea469bc6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md new file mode 100644 index 00000000000000..fac333227088c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md new file mode 100644 index 00000000000000..6364370948976e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index 4ed069d1598fe4..fd56e8ce40e241 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -20,6 +20,7 @@ export interface SavedObjectsFindResponse | --- | --- | --- | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | +| [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) | string | | | [saved\_objects](./kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md) | Array<SavedObjectsFindResult<T>> | | | [total](./kibana-plugin-core-server.savedobjectsfindresponse.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md new file mode 100644 index 00000000000000..dc4f9b509d606a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) > [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) + +## SavedObjectsFindResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md index e455074a7d11bb..0f8e9c59236bbc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md @@ -16,4 +16,5 @@ export interface SavedObjectsFindResult extends SavedObject | Property | Type | Description | | --- | --- | --- | | [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) | number | The Elasticsearch _score of this result. | +| [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) | unknown[] | The Elasticsearch sort value of this result. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md new file mode 100644 index 00000000000000..3cc02c404c8d71 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) > [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) + +## SavedObjectsFindResult.sort property + +The Elasticsearch `sort` value of this result. + +Signature: + +```typescript +sort?: unknown[]; +``` + +## Remarks + +This can be passed directly to the `searchAfter` param in the [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) in order to page through large numbers of hits. It is recommended you use this alongside a Point In Time (PIT) that was opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +## Example + + +```ts +const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md new file mode 100644 index 00000000000000..57752318cb96a9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) + +## SavedObjectsOpenPointInTimeOptions.keepAlive property + +Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md new file mode 100644 index 00000000000000..46516be2329e9c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) + +## SavedObjectsOpenPointInTimeOptions interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) | string | Optionally specify how long ES should keep the PIT alive until the next request. Defaults to 5m. | +| [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) | string | An optional ES preference value to be used for the query. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md new file mode 100644 index 00000000000000..7a9f3a49e8663e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) + +## SavedObjectsOpenPointInTimeOptions.preference property + +An optional ES preference value to be used for the query. + +Signature: + +```typescript +preference?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md new file mode 100644 index 00000000000000..66387e5b3b89fe --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) > [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) + +## SavedObjectsOpenPointInTimeResponse.id property + +PIT ID returned from ES. + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md new file mode 100644 index 00000000000000..c4be2692763a5f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) + +## SavedObjectsOpenPointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) | string | PIT ID returned from ES. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md new file mode 100644 index 00000000000000..cb4d4a65727d7e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) + +## SavedObjectsPitParams.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md new file mode 100644 index 00000000000000..1011a908f210ac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) + +## SavedObjectsPitParams.keepAlive property + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md new file mode 100644 index 00000000000000..7bffca7cda281f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) + +## SavedObjectsPitParams interface + + +Signature: + +```typescript +export interface SavedObjectsPitParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) | string | | +| [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md new file mode 100644 index 00000000000000..8f9dca35fa3629 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md @@ -0,0 +1,58 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) + +## SavedObjectsRepository.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using `openPointInTimeForType`. + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## Remarks + +While the `keepAlive` that is provided will cause a PIT to automatically close, it is highly recommended to explicitly close a PIT when you are done with it in order to avoid consuming unneeded resources in Elasticsearch. + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); + +const response = await repository.find({ + type: 'index-pattern', + search: 'foo*', + sortField: 'name', + sortOrder: 'desc', + pit: { + id: 'abc123', + keepAlive: '2m', + }, + searchAfter: [1234, 'abcd'], +}); + +await repository.closePointInTime(response.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 4d13fea12572c7..632d9c279cb882 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -20,6 +20,7 @@ export declare class SavedObjectsRepository | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | @@ -27,6 +28,7 @@ export declare class SavedObjectsRepository | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | +| [openPointInTimeForType(type, { keepAlive, preference })](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to SavedObjects.find to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md new file mode 100644 index 00000000000000..63956ebee68f7b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md @@ -0,0 +1,57 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) + +## SavedObjectsRepository.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| { keepAlive, preference } | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - { id: string } + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); + +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); + +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md index 1629e774255252..599c4e3ad6319e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md @@ -22,7 +22,7 @@ hits: { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md index b53cbf0d87f240..cbaab4632014d9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md @@ -18,7 +18,8 @@ export interface SearchResponse | [\_scroll\_id](./kibana-plugin-core-server.searchresponse._scroll_id.md) | string | | | [\_shards](./kibana-plugin-core-server.searchresponse._shards.md) | ShardsResponse | | | [aggregations](./kibana-plugin-core-server.searchresponse.aggregations.md) | any | | -| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: string[];
}>;
} | | +| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: unknown[];
}>;
} | | +| [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) | string | | | [timed\_out](./kibana-plugin-core-server.searchresponse.timed_out.md) | boolean | | | [took](./kibana-plugin-core-server.searchresponse.took.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md new file mode 100644 index 00000000000000..f214bc05380455 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SearchResponse](./kibana-plugin-core-server.searchresponse.md) > [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) + +## SearchResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 12a87b1422c5cd..b9fc0c9c4ac466 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -85,6 +85,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a saved object. | `failure` | User is not authorized to create a saved object. +.2+| `saved_object_open_point_in_time` +| `unknown` | User is creating a Point In Time to use when querying saved objects. +| `failure` | User is not authorized to create a Point In Time for the provided saved object types. + .2+| `connector_create` | `unknown` | User is creating a connector. | `failure` | User is not authorized to create a connector. @@ -171,6 +175,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a saved object. | `failure` | User is not authorized to delete a saved object. +.2+| `saved_object_close_point_in_time` +| `unknown` | User is deleting a Point In Time that was used to query saved objects. +| `failure` | User is not authorized to delete a Point In Time. + .2+| `connector_delete` | `unknown` | User is deleting a connector. | `failure` | User is not authorized to delete a connector. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 2922606ac3e1e1..f646972a20f8d0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1207,9 +1207,13 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsPitParams" needs to be exported by the entry point index.d.ts + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "openPointInTimeForType" + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 9c0a44b2d3da0b..44466025de7e31 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -21,12 +21,14 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; +type PromiseType> = T extends Promise ? U : never; + type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' + 'pit' | 'rootSearchFields' | 'searchAfter' | 'sortOrder' | 'typeToNamespacesMap' >; -type PromiseType> = T extends Promise ? U : never; +type SavedObjectsFindResponse = Omit>, 'pit_id'>; /** @public */ export interface SavedObjectsCreateOptions { @@ -345,10 +347,7 @@ export class SavedObjectsClient { query, }); return request.then((resp) => { - return renameKeys< - PromiseType>, - SavedObjectsFindResponsePublic - >( + return renameKeys( { saved_objects: 'savedObjects', total: 'total', diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 2e99398efdfbaa..f5a6fa1f0b1fdc 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -96,10 +96,11 @@ export interface SearchResponse { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; aggregations?: any; + pit_id?: string; } /** diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6f478004c204ef..dac2d210eb395f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -260,6 +260,8 @@ export { SavedObjectsClientWrapperOptions, SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, SavedObjectsCreateOptions, SavedObjectsErrorHelpers, SavedObjectsExportResultDetails, @@ -277,6 +279,8 @@ export { SavedObjectsImportUnsupportedTypeError, SavedObjectMigrationContext, SavedObjectsMigrationLogger, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsRawDoc, SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, @@ -373,6 +377,7 @@ export { SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindOptionsReference, + SavedObjectsPitParams, SavedObjectsMigrationVersion, } from './types'; diff --git a/src/core/server/saved_objects/export/point_in_time_finder.test.ts b/src/core/server/saved_objects/export/point_in_time_finder.test.ts new file mode 100644 index 00000000000000..cd79c7a4b81e5a --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.test.ts @@ -0,0 +1,321 @@ +/* + * 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 { savedObjectsClientMock } from '../service/saved_objects_client.mock'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; +import { SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; + +import { createPointInTimeFinder } from './point_in_time_finder'; + +const mockHits = [ + { + id: '2', + type: 'search', + attributes: {}, + score: 1, + references: [ + { + name: 'name', + type: 'visualization', + id: '1', + }, + ], + sort: [], + }, + { + id: '1', + type: 'visualization', + attributes: {}, + score: 1, + references: [], + sort: [], + }, +]; + +describe('createPointInTimeFinder()', () => { + let logger: MockedLogger; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + logger = loggerMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + describe('#find', () => { + test('throws if a PIT is already open', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + await finder.find().next(); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + savedObjectsClient.find.mockClear(); + + expect(async () => { + await finder.find().next(); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` + ); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(0); + }); + + test('works with a single page of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 2, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + + test('works with multiple pages of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'abc123', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + // called 3 times since we need a 3rd request to check if we + // are done paginating through results. + expect(savedObjectsClient.find).toHaveBeenCalledTimes(3); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + }); + + describe('#close', () => { + test('calls closePointInTime with correct ID', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('causes generator to stop', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'test', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(hits.length).toBe(1); + }); + + test('is called if `find` throws an error', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockRejectedValueOnce(new Error('oops')); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + try { + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + } catch (e) { + // intentionally empty + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('finder can be reused after closing', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + + const findA = finder.find(); + await findA.next(); + await finder.close(); + + const findB = finder.find(); + await findB.next(); + await finder.close(); + + expect((await findA.next()).done).toBe(true); + expect((await findB.next()).done).toBe(true); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/point_in_time_finder.ts b/src/core/server/saved_objects/export/point_in_time_finder.ts new file mode 100644 index 00000000000000..dc0bac6b6bfd99 --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.ts @@ -0,0 +1,192 @@ +/* + * 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 { Logger } from '../../logging'; +import { SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResponse } from '../service'; + +/** + * Returns a generator to help page through large sets of saved objects. + * + * The generator wraps calls to `SavedObjects.find` and iterates over + * multiple pages of results using `_pit` and `search_after`. This will + * open a new Point In Time (PIT), and continue paging until a set of + * results is received that's smaller than the designated `perPage`. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This will automatically be done for + * you if you reach the last page of results. + * + * @example + * ```ts + * const findOptions: SavedObjectsFindOptions = { + * type: 'visualization', + * search: 'foo*', + * perPage: 100, + * }; + * + * const finder = createPointInTimeFinder({ + * logger, + * savedObjectsClient, + * findOptions, + * }); + * + * const responses: SavedObjectFindResponse[] = []; + * for await (const response of finder.find()) { + * responses.push(...response); + * if (doneSearching) { + * await finder.close(); + * } + * } + * ``` + */ +export function createPointInTimeFinder({ + findOptions, + logger, + savedObjectsClient, +}: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +}) { + return new PointInTimeFinder({ findOptions, logger, savedObjectsClient }); +} + +/** + * @internal + */ +export class PointInTimeFinder { + readonly #log: Logger; + readonly #savedObjectsClient: SavedObjectsClientContract; + readonly #findOptions: SavedObjectsFindOptions; + #open: boolean = false; + #pitId?: string; + + constructor({ + findOptions, + logger, + savedObjectsClient, + }: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + }) { + this.#log = logger; + this.#savedObjectsClient = savedObjectsClient; + this.#findOptions = { + // Default to 1000 items per page as a tradeoff between + // speed and memory consumption. + perPage: 1000, + ...findOptions, + }; + } + + async *find() { + if (this.#open) { + throw new Error( + 'Point In Time has already been opened for this finder instance. ' + + 'Please call `close()` before calling `find()` again.' + ); + } + + // Open PIT and request our first page of hits + await this.open(); + + let lastResultsCount: number; + let lastHitSortValue: unknown[] | undefined; + do { + const results = await this.findNext({ + findOptions: this.#findOptions, + id: this.#pitId, + ...(lastHitSortValue ? { searchAfter: lastHitSortValue } : {}), + }); + this.#pitId = results.pit_id; + lastResultsCount = results.saved_objects.length; + lastHitSortValue = this.getLastHitSortValue(results); + + this.#log.debug(`Collected [${lastResultsCount}] saved objects for export.`); + + // Close PIT if this was our last page + if (this.#pitId && lastResultsCount < this.#findOptions.perPage!) { + await this.close(); + } + + yield results; + // We've reached the end when there are fewer hits than our perPage size, + // or when `close()` has been called. + } while (this.#open && lastResultsCount >= this.#findOptions.perPage!); + + return; + } + + async close() { + try { + if (this.#pitId) { + this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`); + await this.#savedObjectsClient.closePointInTime(this.#pitId); + this.#pitId = undefined; + } + this.#open = false; + } catch (e) { + this.#log.error(`Failed to close PIT for types [${this.#findOptions.type}]`); + throw e; + } + } + + private async open() { + try { + const { id } = await this.#savedObjectsClient.openPointInTimeForType(this.#findOptions.type); + this.#pitId = id; + this.#open = true; + } catch (e) { + // Since `find` swallows 404s, it is expected that exporter will do the same, + // so we only rethrow non-404 errors here. + if (e.output.statusCode !== 404) { + throw e; + } + this.#log.debug(`Unable to open PIT for types [${this.#findOptions.type}]: 404 ${e}`); + } + } + + private async findNext({ + findOptions, + id, + searchAfter, + }: { + findOptions: SavedObjectsFindOptions; + id?: string; + searchAfter?: unknown[]; + }) { + try { + return await this.#savedObjectsClient.find({ + // Sort fields are required to use searchAfter, so we set some defaults here + sortField: 'updated_at', + sortOrder: 'desc', + // Bump keep_alive by 2m on every new request to allow for the ES client + // to make multiple retries in the event of a network failure. + ...(id ? { pit: { id, keepAlive: '2m' } } : {}), + ...(searchAfter ? { searchAfter } : {}), + ...findOptions, + }); + } catch (e) { + if (id) { + // Clean up PIT on any errors. + await this.close(); + } + throw e; + } + } + + private getLastHitSortValue(res: SavedObjectsFindResponse): unknown[] | undefined { + if (res.saved_objects.length < 1) { + return undefined; + } + return res.saved_objects[res.saved_objects.length - 1].sort; + } +} diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts index c16623f785b08f..cf60ada5ba90ac 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts @@ -11,6 +11,7 @@ import { SavedObjectsExporter } from './saved_objects_exporter'; import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; import { SavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { httpServerMock } from '../../http/http_server.mocks'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; @@ -18,18 +19,25 @@ async function readStreamToCompletion(stream: Readable): Promise { + let logger: MockedLogger; let savedObjectsClient: ReturnType; let typeRegistry: SavedObjectTypeRegistry; let exporter: SavedObjectsExporter; beforeEach(() => { + logger = loggerMock.create(); typeRegistry = new SavedObjectTypeRegistry(); savedObjectsClient = savedObjectsClientMock.create(); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); }); describe('#exportByTypes', () => { @@ -58,7 +66,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -96,30 +104,232 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + describe('pages through results with PIT', () => { + function generateHits( + hitCount: number, + { + attributes = {}, + sort = [], + type = 'index-pattern', + }: { + attributes?: Record; + sort?: unknown[]; + type?: string; + } = {} + ) { + const hits = []; + for (let i = 1; i <= hitCount; i++) { + hits.push({ + id: `${i}`, + type, + attributes, + sort, + score: 1, + references: [], + }); + } + return hits; + } + + describe('<1k hits', () => { + const mockHits = generateHits(20); + + test('requests a single page', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 20, + "missingRefCount": 0, + "missingReferences": Array [], + } + `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes correct PIT ID to `find`', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['index-pattern'], + }) + ); + }); + }); + + describe('>1k hits', () => { + const firstMockHits = generateHits(1000, { sort: ['a', 'b'] }); + const secondMockHits = generateHits(500); + + test('requests multiple pages', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 1500, + "missingRefCount": 0, + "missingReferences": Array [], + } `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes sort values to searchAfter', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find.mock.calls[1][0]).toEqual( + expect.objectContaining({ + searchAfter: ['a', 'b'], + }) + ); + }); + }); }); test('applies the export transforms', async () => { @@ -138,7 +348,12 @@ describe('getSortedObjectsForExport()', () => { }, }, }); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 1, @@ -233,30 +448,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exclude export details if option is specified', async () => { @@ -383,30 +604,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": "foo", - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": "foo", + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports selected types with references when present', async () => { @@ -465,35 +692,41 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "hasReferenceOperator": "OR", - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": Array [ Object { - "type": "return", - "value": Promise {}, + "id": "1", + "type": "index-pattern", }, ], - } - `); + "hasReferenceOperator": "OR", + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports from the provided namespace when present', async () => { @@ -521,7 +754,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -560,36 +793,56 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": Array [ - "foo", - ], - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": Array [ + "foo", ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('export selected types throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); + + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + + savedObjectsClient.closePointInTime.mockResolvedValueOnce({ + succeeded: true, + num_freed: 1, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 2, @@ -617,6 +870,7 @@ describe('getSortedObjectsForExport()', () => { ], per_page: 1, page: 0, + pit_id: 'abc123', }); await expect( exporter.exportByTypes({ @@ -624,12 +878,13 @@ describe('getSortedObjectsForExport()', () => { types: ['index-pattern', 'search'], }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); }); test('sorts objects within type', async () => { savedObjectsClient.find.mockResolvedValueOnce({ total: 3, - per_page: 10000, + per_page: 1000, page: 1, saved_objects: [ { @@ -836,7 +1091,12 @@ describe('getSortedObjectsForExport()', () => { }); test('export selected objects throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); const exportOpts = { request, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 295a3d7a119d4e..c1c0ea73f0bd3d 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -8,7 +8,9 @@ import { createListStream } from '@kbn/utils'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObject, SavedObjectsClientContract } from '../types'; +import { Logger } from '../../logging'; +import { SavedObject, SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; @@ -21,6 +23,7 @@ import { } from './types'; import { SavedObjectsExportError } from './errors'; import { applyExportTransforms } from './apply_export_transforms'; +import { createPointInTimeFinder } from './point_in_time_finder'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -35,16 +38,20 @@ export class SavedObjectsExporter { readonly #savedObjectsClient: SavedObjectsClientContract; readonly #exportTransforms: Record; readonly #exportSizeLimit: number; + readonly #log: Logger; constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, + logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }) { + this.#log = logger; this.#savedObjectsClient = savedObjectsClient; this.#exportSizeLimit = exportSizeLimit; this.#exportTransforms = typeRegistry.getAllTypes().reduce((transforms, type) => { @@ -66,6 +73,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByTypes(options: SavedObjectsExportByTypeOptions) { + this.#log.debug(`Initiating export for types: [${options.types}]`); const objects = await this.fetchByTypes(options); return this.processObjects(objects, byIdAscComparator, { request: options.request, @@ -83,6 +91,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByObjects(options: SavedObjectsExportByObjectOptions) { + this.#log.debug(`Initiating export of [${options.objects.length}] objects.`); if (options.objects.length > this.#exportSizeLimit) { throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); } @@ -106,6 +115,7 @@ export class SavedObjectsExporter { namespace, }: SavedObjectExportBaseOptions ) { + this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); let exportedObjects: Array>; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; @@ -117,6 +127,7 @@ export class SavedObjectsExporter { }); if (includeReferencesDeep) { + this.#log.debug(`Fetching saved objects references.`); const fetchResult = await fetchNestedDependencies( savedObjects, this.#savedObjectsClient, @@ -138,6 +149,7 @@ export class SavedObjectsExporter { missingRefCount: missingReferences.length, missingReferences, }; + this.#log.debug(`Exporting [${redactedObjects.length}] saved objects.`); return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } @@ -156,21 +168,32 @@ export class SavedObjectsExporter { hasReference, search, }: SavedObjectsExportByTypeOptions) { - const findResponse = await this.#savedObjectsClient.find({ + const findOptions: SavedObjectsFindOptions = { type: types, hasReference, hasReferenceOperator: hasReference ? 'OR' : undefined, search, - perPage: this.#exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + }; + + const finder = createPointInTimeFinder({ + findOptions, + logger: this.#log, + savedObjectsClient: this.#savedObjectsClient, }); - if (findResponse.total > this.#exportSizeLimit) { - throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + if (hits.length > this.#exportSizeLimit) { + await finder.close(); + throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + } } // sorts server-side by _id, since it's only available in fielddata return ( - findResponse.saved_objects + hits // exclude the find-specific `score` property from the exported objects .map(({ score, ...obj }) => obj) .sort(byIdAscComparator) diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 4ad0a34acc2ef9..fce7f123844564 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -459,6 +459,7 @@ export class SavedObjectsService savedObjectsClient, typeRegistry: this.typeRegistry, exportSizeLimit: this.config!.maxImportExportSize, + logger: this.logger.get('exporter'), }), createImporter: (savedObjectsClient) => new SavedObjectsImporter({ diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index c853e208f27aa3..a3610b1e437e24 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -17,6 +17,8 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index aac508fb5b909c..e77143d13612ff 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2813,6 +2813,13 @@ describe('SavedObjectsRepository', () => { expect(client.search).not.toHaveBeenCalled(); }); + it(`throws when a preference is provided with pit`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', pit: { id: 'abc123' }, preference: 'hi' }) + ).rejects.toThrowError('options.preference must be excluded when options.pit is used'); + expect(client.search).not.toHaveBeenCalled(); + }); + it(`throws when KQL filter syntax is invalid`, async () => { const findOpts = { namespaces: [namespace], @@ -2973,6 +2980,32 @@ describe('SavedObjectsRepository', () => { }); }); + it(`accepts searchAfter`, async () => { + const relevantOpts = { + ...commonOptions, + searchAfter: [1, 'a'], + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + searchAfter: [1, 'a'], + }); + }); + + it(`accepts pit`, async () => { + const relevantOpts = { + ...commonOptions, + pit: { id: 'abc123', keepAlive: '2m' }, + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + pit: { id: 'abc123', keepAlive: '2m' }, + }); + }); + it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { namespaces: [namespace], @@ -4393,4 +4426,136 @@ describe('SavedObjectsRepository', () => { }); }); }); + + describe('#openPointInTimeForType', () => { + const type = 'index-pattern'; + + const generateResults = (id) => ({ id: id || null }); + const successResponse = async (type, options) => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.openPointInTimeForType(type, options); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts preference`, async () => { + await successResponse(type, { preference: 'pref' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + preference: 'pref', + }), + expect.anything() + ); + }); + + it(`accepts keepAlive`, async () => { + await successResponse(type, { keepAlive: '2m' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '2m', + }), + expect.anything() + ); + }); + + it(`defaults keepAlive to 5m`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '5m', + }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + const expectNotFoundError = async (types) => { + await expect(savedObjectsRepository.openPointInTimeForType(types)).rejects.toThrowError( + createGenericNotFoundError() + ); + }; + + it(`throws when ES is unable to find the index`, async () => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`should return generic not found error when attempting to find only invalid or hidden types`, async () => { + const test = async (types) => { + await expectNotFoundError(types); + expect(client.openPointInTime).not.toHaveBeenCalled(); + }; + + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); + }); + }); + + describe('returns', () => { + it(`returns id in the expected format`, async () => { + const id = 'abc123'; + const results = generateResults(id); + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.openPointInTimeForType(type); + expect(response).toEqual({ id }); + }); + }); + }); + + describe('#closePointInTime', () => { + const generateResults = () => ({ succeeded: true, num_freed: 3 }); + const successResponse = async (id) => { + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.closePointInTime(id); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts id`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + id: 'abc123', + }), + }), + expect.anything() + ); + }); + }); + + describe('returns', () => { + it(`returns response body from ES`, async () => { + const results = generateResults('abc123'); + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.closePointInTime('abc123'); + expect(response).toEqual(results); + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index fcd72aa4326a20..b8a72377b0d764 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -36,6 +36,10 @@ import { SavedObjectsCreateOptions, SavedObjectsFindResponse, SavedObjectsFindResult, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsBulkUpdateObject, @@ -708,11 +712,13 @@ export class SavedObjectsRepository { * Query field argument for more information * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] + * @property {Array} [options.searchAfter] * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] * @property {object} [options.hasReference] - { type, id } + * @property {string} [options.pit] * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ @@ -726,6 +732,8 @@ export class SavedObjectsRepository { hasReferenceOperator, page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, + pit, + searchAfter, sortField, sortOrder, fields, @@ -752,6 +760,10 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' ); + } else if (preference?.length && pit) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.preference must be excluded when options.pit is used' + ); } const types = type @@ -787,20 +799,24 @@ export class SavedObjectsRepository { } const esOptions = { - index: this.getIndicesForTypes(allowedTypes), - size: perPage, - from: perPage * (page - 1), + // If `pit` is provided, we drop the `index`, otherwise ES returns 400. + ...(pit ? {} : { index: this.getIndicesForTypes(allowedTypes) }), + // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. + ...(searchAfter ? {} : { from: perPage * (page - 1) }), _source: includedFields(type, fields), - rest_total_hits_as_int: true, preference, + rest_total_hits_as_int: true, + size: perPage, body: { seq_no_primary_term: true, ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, searchFields, + pit, rootSearchFields, type: allowedTypes, + searchAfter, sortField, sortOrder, namespaces, @@ -834,8 +850,10 @@ export class SavedObjectsRepository { (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ ...this._rawToSavedObject(hit), score: (hit as any)._score, + ...((hit as any).sort && { sort: (hit as any).sort }), }) ), + ...(body.pit_id && { pit_id: body.pit_id }), } as SavedObjectsFindResponse; } @@ -1764,6 +1782,118 @@ export class SavedObjectsRepository { }; } + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType( + * type: 'visualization', + * { keepAlive: '5m' }, + * ); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id, keepAlive: '2m' }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + { keepAlive = '5m', preference }: SavedObjectsOpenPointInTimeOptions = {} + ): Promise { + const types = Array.isArray(type) ? type : [type]; + const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); + if (allowedTypes.length === 0) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + const esOptions = { + index: this.getIndicesForTypes(allowedTypes), + keep_alive: keepAlive, + ...(preference ? { preference } : {}), + }; + + const { + body, + statusCode, + } = await this.client.openPointInTime(esOptions, { + ignore: [404], + }); + if (statusCode === 404) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + return { + id: body.id, + }; + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @remarks + * While the `keepAlive` that is provided will cause a PIT to automatically close, + * it is highly recommended to explicitly close a PIT when you are done with it + * in order to avoid consuming unneeded resources in Elasticsearch. + * + * @example + * ```ts + * const repository = coreStart.savedObjects.createInternalRepository(); + * + * const { id } = await repository.openPointInTimeForType( + * type: 'index-pattern', + * { keepAlive: '2m' }, + * ); + * + * const response = await repository.find({ + * type: 'index-pattern', + * search: 'foo*', + * sortField: 'name', + * sortOrder: 'desc', + * pit: { + * id: 'abc123', + * keepAlive: '2m', + * }, + * searchAfter: [1234, 'abcd'], + * }); + * + * await repository.closePointInTime(response.pit_id); + * ``` + * + * @param {string} id + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - {@link SavedObjectsClosePointInTimeResponse} + */ + async closePointInTime( + id: string, + options?: SavedObjectsClosePointInTimeOptions + ): Promise { + const { body } = await this.client.closePointInTime({ + body: { id }, + }); + return body; + } + /** * Returns index specified by the given type or the default index * diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.ts b/src/core/server/saved_objects/service/lib/repository_es_client.ts index dae72819726ad2..6a601b1ed0c839 100644 --- a/src/core/server/saved_objects/service/lib/repository_es_client.ts +++ b/src/core/server/saved_objects/service/lib/repository_es_client.ts @@ -14,11 +14,13 @@ import { decorateEsError } from './decorate_es_error'; const methods = [ 'bulk', + 'closePointInTime', 'create', 'delete', 'get', 'index', 'mget', + 'openPointInTime', 'search', 'update', 'updateByQuery', diff --git a/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts new file mode 100644 index 00000000000000..5a99168792e83b --- /dev/null +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.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 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 { getPitParams } from './pit_params'; + +describe('searchDsl/getPitParams', () => { + it('returns only an ID by default', () => { + expect(getPitParams({ id: 'abc123' })).toEqual({ + pit: { + id: 'abc123', + }, + }); + }); + + it('includes keepAlive if provided and rewrites to snake case', () => { + expect(getPitParams({ id: 'abc123', keepAlive: '2m' })).toEqual({ + pit: { + id: 'abc123', + keep_alive: '2m', + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts new file mode 100644 index 00000000000000..1a8dcb5cca2e9d --- /dev/null +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SavedObjectsPitParams } from '../../../types'; + +export function getPitParams(pit: SavedObjectsPitParams) { + return { + pit: { + id: pit.id, + ...(pit.keepAlive ? { keep_alive: pit.keepAlive } : {}), + }, + }; +} diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 9e91e585f74f04..fc26c837d5e52f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -6,14 +6,17 @@ * Side Public License, v 1. */ +jest.mock('./pit_params'); jest.mock('./query_params'); jest.mock('./sorting_params'); import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import * as pitParamsNS from './pit_params'; import * as queryParamsNS from './query_params'; import { getSearchDsl } from './search_dsl'; import * as sortParamsNS from './sorting_params'; +const getPitParams = pitParamsNS.getPitParams as jest.Mock; const getQueryParams = queryParamsNS.getQueryParams as jest.Mock; const getSortingParams = sortParamsNS.getSortingParams as jest.Mock; @@ -84,6 +87,7 @@ describe('getSearchDsl', () => { type: 'foo', sortField: 'bar', sortOrder: 'baz', + pit: { id: 'abc123' }, }; getSearchDsl(mappings, registry, opts); @@ -92,7 +96,8 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder + opts.sortOrder, + opts.pit ); }); @@ -101,5 +106,33 @@ describe('getSearchDsl', () => { getSortingParams.mockReturnValue({ b: 'b' }); expect(getSearchDsl(mappings, registry, { type: 'foo' })).toEqual({ a: 'a', b: 'b' }); }); + + it('returns searchAfter if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + expect(getSearchDsl(mappings, registry, { type: 'foo', searchAfter: [1, 'bar'] })).toEqual({ + a: 'a', + b: 'b', + search_after: [1, 'bar'], + }); + }); + + it('returns pit if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + getPitParams.mockReturnValue({ pit: { id: 'abc123' } }); + expect( + getSearchDsl(mappings, registry, { + type: 'foo', + searchAfter: [1, 'bar'], + pit: { id: 'abc123' }, + }) + ).toEqual({ + a: 'a', + b: 'b', + pit: { id: 'abc123' }, + search_after: [1, 'bar'], + }); + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 4b4fa8865ee9d8..cae5e43897bcfe 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -9,7 +9,9 @@ import Boom from '@hapi/boom'; import { IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; import { getQueryParams, HasReferenceQueryParams, SearchOperator } from './query_params'; +import { getPitParams } from './pit_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; @@ -21,9 +23,11 @@ interface GetSearchDslOptions { defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; + searchAfter?: unknown[]; sortField?: string; sortOrder?: string; namespaces?: string[]; + pit?: SavedObjectsPitParams; typeToNamespacesMap?: Map; hasReference?: HasReferenceQueryParams | HasReferenceQueryParams[]; hasReferenceOperator?: SearchOperator; @@ -41,9 +45,11 @@ export function getSearchDsl( defaultSearchOperator, searchFields, rootSearchFields, + searchAfter, sortField, sortOrder, namespaces, + pit, typeToNamespacesMap, hasReference, hasReferenceOperator, @@ -72,6 +78,8 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder), + ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...(pit ? getPitParams(pit) : {}), + ...(searchAfter ? { search_after: searchAfter } : {}), }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 1376f0d50a9da6..73c7065705fc54 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,6 +79,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( + expect.arrayContaining([{ _shard_doc: 'asc' }]) + ); + }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -93,6 +98,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -114,6 +124,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -128,6 +143,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -142,6 +162,12 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) + .sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index e3bfba6a80f59d..abef9bfa0a3006 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,6 +8,12 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; + +// TODO: The plan is for ES to automatically add this tiebreaker when +// using PIT. We should remove this logic once that is resolved. +// https://github.com/elastic/elasticsearch/issues/56828 +const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -15,7 +21,8 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string + sortOrder?: string, + pit?: SavedObjectsPitParams ) { if (!sortField) { return {}; @@ -31,6 +38,7 @@ export function getSortingParams( order: sortOrder, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -51,6 +59,7 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -75,6 +84,7 @@ export function getSortingParams( unmapped_type: field.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 72f5561aa70279..ecca652cace37f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -20,6 +20,8 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 45b0cf70b0dc6e..7cbddaf195dc93 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -115,6 +115,36 @@ test(`#get`, async () => { expect(result).toBe(returnValue); }); +test(`#openPointInTimeForType`, async () => { + const returnValue = Symbol(); + const mockRepository = { + openPointInTimeForType: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const options = Symbol(); + const result = await client.openPointInTimeForType(type, options); + + expect(mockRepository.openPointInTimeForType).toHaveBeenCalledWith(type, options); + expect(result).toBe(returnValue); +}); + +test(`#closePointInTime`, async () => { + const returnValue = Symbol(); + const mockRepository = { + closePointInTime: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const id = Symbol(); + const options = Symbol(); + const result = await client.closePointInTime(id, options); + + expect(mockRepository.closePointInTime).toHaveBeenCalledWith(id, options); + expect(result).toBe(returnValue); +}); + test(`#resolve`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b90540fbfa9716..b93f3022e4236c 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -129,6 +129,35 @@ export interface SavedObjectsFindResult extends SavedObject { * The Elasticsearch `_score` of this result. */ score: number; + /** + * The Elasticsearch `sort` value of this result. + * + * @remarks + * This can be passed directly to the `searchAfter` param in the {@link SavedObjectsFindOptions} + * in order to page through large numbers of hits. It is recommended you use this alongside + * a Point In Time (PIT) that was opened with {@link SavedObjectsClient.openPointInTimeForType}. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + */ + sort?: unknown[]; } /** @@ -144,6 +173,7 @@ export interface SavedObjectsFindResponse { total: number; per_page: number; page: number; + pit_id?: string; } /** @@ -311,6 +341,50 @@ export interface SavedObjectsResolveResponse { outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; } +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + /** + * Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + */ + keepAlive?: string; + /** + * An optional ES preference value to be used for the query. + */ + preference?: string; +} + +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeResponse { + /** + * PIT ID returned from ES. + */ + id: string; +} + +/** + * @public + */ +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +/** + * @public + */ +export interface SavedObjectsClosePointInTimeResponse { + /** + * If true, all search contexts associated with the PIT id are + * successfully closed. + */ + succeeded: boolean; + /** + * The number of search contexts that have been successfully closed. + */ + num_freed: number; +} + /** * * @public @@ -504,4 +578,25 @@ export class SavedObjectsClient { ) { return await this._repository.removeReferencesTo(type, id, options); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to {@link SavedObjectsClient.find} to search + * against that PIT. + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + return await this._repository.openPointInTimeForType(type, options); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the + * Elasticsearch client, and is included in the Saved Objects Client as a convenience + * for consumers who are using {@link SavedObjectsClient.openPointInTimeForType}. + */ + async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + return await this._repository.closePointInTime(id, options); + } } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index d122e92aba3989..66110d096213f5 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -62,6 +62,14 @@ export interface SavedObjectsFindOptionsReference { id: string; } +/** + * @public + */ +export interface SavedObjectsPitParams { + id: string; + keepAlive?: string; +} + /** * * @public @@ -82,6 +90,10 @@ export interface SavedObjectsFindOptions { search?: string; /** The fields to perform the parsed query against. See Elasticsearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** + * Use the sort values from the previous page to retrieve the next page of results. + */ + searchAfter?: unknown[]; /** * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not * be modified. If used in conjunction with `searchFields`, both are concatenated together. @@ -114,6 +126,10 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional ES preference value to be used for the query **/ preference?: string; + /** + * Search against a specific Point In Time (PIT) that you've opened with {@link SavedObjectsClient.openPointInTimeForType}. + */ + pit?: SavedObjectsPitParams; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 6db053d7aa5d59..b5f8b9d69abf31 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2223,6 +2223,7 @@ export class SavedObjectsClient { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; @@ -2232,6 +2233,7 @@ export class SavedObjectsClient { errors: typeof SavedObjectsErrorHelpers; find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; + openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2270,6 +2272,15 @@ export interface SavedObjectsClientWrapperOptions { typeRegistry: ISavedObjectTypeRegistry; } +// @public (undocumented) +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +// @public (undocumented) +export interface SavedObjectsClosePointInTimeResponse { + num_freed: number; + succeeded: boolean; +} + // @public export interface SavedObjectsComplexFieldMapping { // (undocumented) @@ -2420,10 +2431,11 @@ export interface SavedObjectsExportByTypeOptions extends SavedObjectExportBaseOp export class SavedObjectsExporter { // (undocumented) #private; - constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { + constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); exportByObjects(options: SavedObjectsExportByObjectOptions): Promise; exportByTypes(options: SavedObjectsExportByTypeOptions): Promise; @@ -2481,9 +2493,11 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; @@ -2509,6 +2523,8 @@ export interface SavedObjectsFindResponse { // (undocumented) per_page: number; // (undocumented) + pit_id?: string; + // (undocumented) saved_objects: Array>; // (undocumented) total: number; @@ -2517,6 +2533,7 @@ export interface SavedObjectsFindResponse { // @public (undocumented) export interface SavedObjectsFindResult extends SavedObject { score: number; + sort?: unknown[]; } // @public @@ -2743,6 +2760,25 @@ export interface SavedObjectsMigrationVersion { // @public export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + keepAlive?: string; + preference?: string; +} + +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeResponse { + id: string; +} + +// @public (undocumented) +export interface SavedObjectsPitParams { + // (undocumented) + id: string; + // (undocumented) + keepAlive?: string; +} + // @public export interface SavedObjectsRawDoc { // (undocumented) @@ -2779,6 +2815,7 @@ export class SavedObjectsRepository { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2791,6 +2828,7 @@ export class SavedObjectsRepository { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; + openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2955,10 +2993,12 @@ export interface SearchResponse { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; // (undocumented) + pit_id?: string; + // (undocumented) _scroll_id?: string; // (undocumented) _shards: ShardsResponse; diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 1839ee68190aa5..2ae51d4452a4e6 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -31,6 +31,7 @@ export type { SavedObjectStatusMeta, SavedObjectsFindOptionsReference, SavedObjectsFindOptions, + SavedObjectsPitParams, SavedObjectsBaseOptions, MutatingOperationRefreshSetting, SavedObjectsClientContract, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 7cf7e7e2c8d5e8..ab8f6c9ed39511 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1138,7 +1138,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index 5206d51054745c..8af2dbdea31dc8 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -295,43 +295,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -355,43 +355,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -420,43 +420,43 @@ export default function ({ getService }: FtrProviderContext) { ); expect(resp.header['content-type']).to.eql('application/ndjson'); const objects = ndjsonToObject(resp.text); - expect(objects).to.eql([ - { - attributes: { - description: '', - hits: 0, - kibanaSavedObjectMeta: { - searchSourceJSON: - objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, - }, - optionsJSON: objects[0].attributes.optionsJSON, - panelsJSON: objects[0].attributes.panelsJSON, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', - timeRestore: true, - timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', - title: 'Requests', - version: 1, + + // Sort values aren't deterministic so we need to exclude them + const { sort, ...obj } = objects[0]; + expect(obj).to.eql({ + attributes: { + description: '', + hits: 0, + kibanaSavedObjectMeta: { + searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, }, - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - migrationVersion: objects[0].migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - references: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - name: 'panel_0', - type: 'visualization', - }, - ], - type: 'dashboard', - updated_at: '2017-09-21T18:57:40.826Z', - version: objects[0].version, + optionsJSON: objects[0].attributes.optionsJSON, + panelsJSON: objects[0].attributes.panelsJSON, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700', + timeRestore: true, + timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700', + title: 'Requests', + version: 1, }, - ]); + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + migrationVersion: objects[0].migrationVersion, + coreMigrationVersion: KIBANA_VERSION, + references: [ + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + name: 'panel_0', + type: 'visualization', + }, + ], + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: objects[0].version, + }); expect(objects[0].migrationVersion).to.be.ok(); expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON) @@ -511,7 +511,37 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('saved_objects/10k'); }); - it('should return 400 when exporting more than 10,000', async () => { + it('should allow exporting more than 10,000 objects if permitted by maxImportExportSize', async () => { + await supertest + .post('/api/saved_objects/_export') + .send({ + type: ['dashboard', 'visualization', 'search', 'index-pattern'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + expect(resp.header['content-disposition']).to.eql( + 'attachment; filename="export.ndjson"' + ); + expect(resp.header['content-type']).to.eql('application/ndjson'); + const objects = ndjsonToObject(resp.text); + expect(objects.length).to.eql(10001); + }); + }); + + it('should return 400 when exporting more than allowed by maxImportExportSize', async () => { + let anotherCustomVisId: string; + await supertest + .post('/api/saved_objects/visualization') + .send({ + attributes: { + title: 'My other favorite vis', + }, + }) + .expect(200) + .then((resp) => { + anotherCustomVisId = resp.body.id; + }); await supertest .post('/api/saved_objects/_export') .send({ @@ -523,9 +553,13 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: `Can't export more than 10000 objects`, + message: `Can't export more than 10001 objects`, }); }); + await supertest + // @ts-expect-error TS complains about using `anotherCustomVisId` before it is assigned + .delete(`/api/saved_objects/visualization/${anotherCustomVisId}`) + .expect(200); }); }); }); diff --git a/test/api_integration/apis/saved_objects/import.ts b/test/api_integration/apis/saved_objects/import.ts index b0aa9b0eef8fc7..d463b9498a52ab 100644 --- a/test/api_integration/apis/saved_objects/import.ts +++ b/test/api_integration/apis/saved_objects/import.ts @@ -166,7 +166,7 @@ export default function ({ getService }: FtrProviderContext) { it('should return 400 when trying to import more than 10,000 objects', async () => { const fileChunks = []; - for (let i = 0; i < 10001; i++) { + for (let i = 0; i <= 10001; i++) { fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); } await supertest @@ -177,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: "Can't import more than 10000 objects", + message: "Can't import more than 10001 objects", }); }); }); diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.ts b/test/api_integration/apis/saved_objects/resolve_import_errors.ts index b203a2c7b7071b..b93f3a52d73d94 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.ts +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.ts @@ -167,9 +167,9 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return 400 when resolving conflicts with a file containing more than 10,000 objects', async () => { + it('should return 400 when resolving conflicts with a file containing more than 10,001 objects', async () => { const fileChunks = []; - for (let i = 0; i < 10001; i++) { + for (let i = 0; i <= 10001; i++) { fileChunks.push(`{"type":"visualization","id":"${i}","attributes":{},"references":[]}`); } await supertest @@ -181,7 +181,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ statusCode: 400, error: 'Bad Request', - message: "Can't import more than 10000 objects", + message: "Can't import more than 10001 objects", }); }); }); diff --git a/test/api_integration/config.js b/test/api_integration/config.js index bd8f10606a45ae..1c19dd24fa96b6 100644 --- a/test/api_integration/config.js +++ b/test/api_integration/config.js @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }) { '--elasticsearch.healthCheck.delay=3600000', '--server.xsrf.disableProtection=true', '--server.compression.referrerWhitelist=["some-host.com"]', + `--savedObjects.maxImportExportSize=10001`, ], }, }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 3405f196960cd1..474a283b5e3cb6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -1757,3 +1757,65 @@ describe('#removeReferencesTo', () => { expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); }); }); + +describe('#openPointInTimeForType', () => { + it('redirects request to underlying base client', async () => { + const options = { keepAlive: '1m' }; + + await wrapper.openPointInTimeForType('some-type', options); + + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledWith('some-type', options); + }); + + it('returns response from underlying client', async () => { + const returnValue = { + id: 'abc123', + }; + mockBaseClient.openPointInTimeForType.mockResolvedValue(returnValue); + + const result = await wrapper.openPointInTimeForType('known-type'); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.openPointInTimeForType.mockRejectedValue(failureReason); + + await expect(wrapper.openPointInTimeForType('known-type')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + }); +}); + +describe('#closePointInTime', () => { + it('redirects request to underlying base client', async () => { + const id = 'abc123'; + await wrapper.closePointInTime(id); + + expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockBaseClient.closePointInTime).toHaveBeenCalledWith(id, undefined); + }); + + it('returns response from underlying client', async () => { + const returnValue = { + succeeded: true, + num_freed: 1, + }; + mockBaseClient.closePointInTime.mockResolvedValue(returnValue); + + const result = await wrapper.closePointInTime('abc123'); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.closePointInTime.mockRejectedValue(failureReason); + + await expect(wrapper.closePointInTime('abc123')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 73414e8559192a..a602f3606e0a99 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -15,9 +15,11 @@ import { SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, SavedObjectsClientContract, + SavedObjectsClosePointInTimeOptions, SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsFindResponse, + SavedObjectsOpenPointInTimeOptions, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsAddToNamespacesOptions, @@ -249,6 +251,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.removeReferencesTo(type, id, options); } + public async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + return await this.options.baseClient.openPointInTimeForType(type, options); + } + + public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + return await this.options.baseClient.closePointInTime(id, options); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 25bcfd683b0dc3..f353362e335133 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -190,6 +190,8 @@ export enum SavedObjectAction { ADD_TO_SPACES = 'saved_object_add_to_spaces', DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', REMOVE_REFERENCES = 'saved_object_remove_references', + OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', + CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', } type VerbsTuple = [string, string, string]; @@ -203,6 +205,16 @@ const savedObjectAuditVerbs: Record = { saved_object_find: ['access', 'accessing', 'accessed'], saved_object_add_to_spaces: ['update', 'updating', 'updated'], saved_object_delete_from_spaces: ['update', 'updating', 'updated'], + saved_object_open_point_in_time: [ + 'open point-in-time', + 'opening point-in-time', + 'opened point-in-time', + ], + saved_object_close_point_in_time: [ + 'close point-in-time', + 'closing point-in-time', + 'closed point-in-time', + ], saved_object_remove_references: [ 'remove references to', 'removing references to', @@ -219,6 +231,8 @@ const savedObjectAuditTypes: Record = { saved_object_find: EventType.ACCESS, saved_object_add_to_spaces: EventType.CHANGE, saved_object_delete_from_spaces: EventType.CHANGE, + saved_object_open_point_in_time: EventType.CREATION, + saved_object_close_point_in_time: EventType.DELETION, saved_object_remove_references: EventType.CHANGE, }; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 571d588037f36a..3a0d9f4a5a100c 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -9,7 +9,13 @@ import { flatten, uniq } from 'lodash'; import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; -const readOperations: string[] = ['bulk_get', 'get', 'find']; +const readOperations: string[] = [ + 'bulk_get', + 'get', + 'find', + 'open_point_in_time', + 'close_point_in_time', +]; const writeOperations: string[] = [ 'create', 'bulk_create', diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 6c2a57e57dcd8d..da2639aba1c6b3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -101,6 +101,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-1', 'create'), actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), actions.savedObject.get('all-savedObject-all-1', 'update'), @@ -110,6 +112,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-2', 'create'), actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), actions.savedObject.get('all-savedObject-all-2', 'update'), @@ -119,9 +123,13 @@ describe('features', () => { actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), actions.savedObject.get('all-savedObject-read-2', 'get'), actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'all-ui-1'), actions.ui.get('foo', 'all-ui-2'), ]; @@ -132,6 +140,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -141,6 +151,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -150,9 +162,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]; @@ -274,6 +290,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-1', 'create'), actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), actions.savedObject.get('all-savedObject-all-1', 'update'), @@ -283,6 +301,8 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('all-savedObject-all-2', 'create'), actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), actions.savedObject.get('all-savedObject-all-2', 'update'), @@ -292,9 +312,13 @@ describe('features', () => { actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), actions.savedObject.get('all-savedObject-read-2', 'get'), actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'all-ui-1'), actions.ui.get('foo', 'all-ui-2'), actions.ui.get('catalogue', 'read-catalogue-1'), @@ -304,6 +328,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -313,6 +339,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -322,9 +350,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]); @@ -388,6 +420,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), actions.savedObject.get('read-savedObject-all-1', 'get'), actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-1', 'create'), actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), actions.savedObject.get('read-savedObject-all-1', 'update'), @@ -397,6 +431,8 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('read-savedObject-all-2', 'create'), actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), actions.savedObject.get('read-savedObject-all-2', 'update'), @@ -406,9 +442,13 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), actions.savedObject.get('read-savedObject-read-2', 'get'), actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), ]); @@ -691,6 +731,8 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-1', 'bulk_get'), actions.savedObject.get('savedObject-all-1', 'get'), actions.savedObject.get('savedObject-all-1', 'find'), + actions.savedObject.get('savedObject-all-1', 'open_point_in_time'), + actions.savedObject.get('savedObject-all-1', 'close_point_in_time'), actions.savedObject.get('savedObject-all-1', 'create'), actions.savedObject.get('savedObject-all-1', 'bulk_create'), actions.savedObject.get('savedObject-all-1', 'update'), @@ -700,6 +742,8 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-2', 'bulk_get'), actions.savedObject.get('savedObject-all-2', 'get'), actions.savedObject.get('savedObject-all-2', 'find'), + actions.savedObject.get('savedObject-all-2', 'open_point_in_time'), + actions.savedObject.get('savedObject-all-2', 'close_point_in_time'), actions.savedObject.get('savedObject-all-2', 'create'), actions.savedObject.get('savedObject-all-2', 'bulk_create'), actions.savedObject.get('savedObject-all-2', 'update'), @@ -709,9 +753,13 @@ describe('reserved', () => { actions.savedObject.get('savedObject-read-1', 'bulk_get'), actions.savedObject.get('savedObject-read-1', 'get'), actions.savedObject.get('savedObject-read-1', 'find'), + actions.savedObject.get('savedObject-read-1', 'open_point_in_time'), + actions.savedObject.get('savedObject-read-1', 'close_point_in_time'), actions.savedObject.get('savedObject-read-2', 'bulk_get'), actions.savedObject.get('savedObject-read-2', 'get'), actions.savedObject.get('savedObject-read-2', 'find'), + actions.savedObject.get('savedObject-read-2', 'open_point_in_time'), + actions.savedObject.get('savedObject-read-2', 'close_point_in_time'), actions.ui.get('foo', 'ui-1'), actions.ui.get('foo', 'ui-2'), ]); @@ -823,6 +871,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -832,6 +882,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -952,6 +1004,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -961,6 +1015,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -970,6 +1026,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -979,6 +1037,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -995,6 +1055,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1004,6 +1066,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1026,6 +1090,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1035,6 +1101,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1044,6 +1112,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1053,6 +1123,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1063,6 +1135,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1072,6 +1146,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1081,6 +1157,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1090,6 +1168,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1160,6 +1240,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1169,6 +1251,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1178,6 +1262,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1187,6 +1273,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1203,6 +1291,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1212,6 +1302,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1304,6 +1396,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1313,6 +1407,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1322,6 +1418,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1331,6 +1429,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1365,6 +1465,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1374,6 +1476,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1389,6 +1493,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1398,6 +1504,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1473,6 +1581,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1482,6 +1592,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1491,6 +1603,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1500,6 +1614,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1606,6 +1722,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1615,6 +1733,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1627,6 +1747,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1636,6 +1758,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1654,6 +1778,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1663,6 +1789,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1672,6 +1800,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1681,6 +1811,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1691,6 +1823,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1700,6 +1834,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1709,6 +1845,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1718,6 +1856,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1808,6 +1948,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1817,6 +1959,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1833,6 +1977,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1842,6 +1988,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1864,6 +2012,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1873,6 +2023,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1882,6 +2034,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1891,6 +2045,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1901,6 +2057,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1910,6 +2068,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -1919,6 +2079,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -1928,6 +2090,8 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), ]); @@ -2018,6 +2182,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2027,6 +2193,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2036,9 +2204,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2056,6 +2228,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2065,6 +2239,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2074,9 +2250,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2100,6 +2280,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2109,6 +2291,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2118,9 +2302,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2131,6 +2319,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2140,6 +2330,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2149,9 +2341,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2163,6 +2359,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2172,6 +2370,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2181,9 +2381,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), @@ -2194,6 +2398,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-sub-feature-type', 'get'), actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-sub-feature-type', 'create'), actions.savedObject.get('all-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-sub-feature-type', 'update'), @@ -2203,6 +2409,8 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), actions.savedObject.get('all-licensed-sub-feature-type', 'find'), + actions.savedObject.get('all-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('all-licensed-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('all-licensed-sub-feature-type', 'create'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_create'), actions.savedObject.get('all-licensed-sub-feature-type', 'update'), @@ -2212,9 +2420,13 @@ describe('subFeatures', () => { actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), + actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'), actions.savedObject.get('read-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-licensed-sub-feature-type', 'get'), actions.savedObject.get('read-licensed-sub-feature-type', 'find'), + actions.savedObject.get('read-licensed-sub-feature-type', 'open_point_in_time'), + actions.savedObject.get('read-licensed-sub-feature-type', 'close_point_in_time'), actions.ui.get('foo', 'foo'), actions.ui.get('foo', 'sub-feature-ui'), actions.ui.get('foo', 'licensed-sub-feature-ui'), diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index aeddba051a186c..1293d3f2c84a32 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -905,6 +905,17 @@ describe('#find', () => { ); }); + test(`throws BadRequestError when searching across namespaces when pit is provided`, async () => { + const options = { + type: [type1, type2], + pit: { id: 'abc123' }, + namespaces: ['some-ns', 'another-ns'], + }; + await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( + `"_find across namespaces is not permitted when using the \`pit\` option."` + ); + }); + test(`checks privileges for user, actions, and namespaces`, async () => { const options = { type: [type1, type2], namespaces }; await expectPrivilegeCheck(client.find, { options }, namespaces); @@ -987,6 +998,64 @@ describe('#get', () => { }); }); +describe('#openPointInTimeForType', () => { + const type = 'foo'; + const namespace = 'some-ns'; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.openPointInTimeForType, { type }); + }); + + test(`returns result of baseClient.openPointInTimeForType when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await expectSuccess(client.openPointInTimeForType, { type, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.openPointInTimeForType, { type, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_open_point_in_time', EventOutcome.UNKNOWN); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.openPointInTimeForType(type, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_open_point_in_time', EventOutcome.FAILURE); + }); +}); + +describe('#closePointInTime', () => { + const id = 'abc123'; + const namespace = 'some-ns'; + + test(`returns result of baseClient.closePointInTime`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await client.closePointInTime(id, options); + expect(result).toBe(apiCallReturnValue); + }); + + test(`adds audit event`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + await client.closePointInTime(id, options); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_close_point_in_time', EventOutcome.UNKNOWN); + }); +}); + describe('#resolve', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 4a886e5addb462..73bee302363abf 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -17,6 +17,8 @@ import { SavedObjectsCreateOptions, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsClosePointInTimeOptions, SavedObjectsRemoveReferencesToOptions, SavedObjectsUpdateOptions, SavedObjectsUtils, @@ -223,6 +225,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra `_find across namespaces is not permitted when the Spaces plugin is disabled.` ); } + if (options.pit && Array.isArray(options.namespaces) && options.namespaces.length > 1) { + throw this.errors.createBadRequestError( + '_find across namespaces is not permitted when using the `pit` option.' + ); + } const args = { options }; const { status, typeMap } = await this.ensureAuthorized( @@ -562,6 +569,57 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.removeReferencesTo(type, id, options); } + public async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions + ) { + try { + const args = { type, options }; + await this.ensureAuthorized(type, 'open_point_in_time', options?.namespace, { + args, + // Partial authorization is acceptable in this case because this method is only designed + // to be used with `find`, which already allows for partial authorization. + requireFullAuthorization: false, + }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.OPEN_POINT_IN_TIME, + error, + }) + ); + throw error; + } + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.OPEN_POINT_IN_TIME, + outcome: EventOutcome.UNKNOWN, + }) + ); + + return await this.baseClient.openPointInTimeForType(type, options); + } + + public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + // We are intentionally omitting a call to `ensureAuthorized` here, because `closePointInTime` + // doesn't take in `types`, which are required to perform authorization. As there is no way + // to know what index/indices a PIT was created against, we have no practical means of + // authorizing users. We've decided we are okay with this because: + // (a) Elasticsearch only requires `read` privileges on an index in order to open/close + // a PIT against it, and; + // (b) By the time a user is accessing this service, they are already authenticated + // to Kibana, which is our closest equivalent to Elasticsearch's `read`. + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CLOSE_POINT_IN_TIME, + outcome: EventOutcome.UNKNOWN, + }) + ); + + return await this.baseClient.closePointInTime(id, options); + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 8a749b5009334c..f5917e78135ecc 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -589,5 +589,57 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); }); + + describe('#openPointInTimeForType', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.openPointInTimeForType('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { id: 'abc123' }; + baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.openPointInTimeForType('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#closePointInTime', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.closePointInTime('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { succeeded: true, num_freed: 1 }; + baseClient.closePointInTime.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.closePointInTime('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.closePointInTime).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 9316d86b19bdd8..433f95d2b5cf6b 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -15,6 +15,8 @@ import { SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, + SavedObjectsClosePointInTimeOptions, + SavedObjectsOpenPointInTimeOptions, SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, @@ -378,4 +380,42 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { namespace: spaceIdToNamespace(this.spaceId), }); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + return await this.client.openPointInTimeForType(type, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @param {string} id - ID returned from `openPointInTimeForType` + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - { succeeded: boolean; num_freed: number } + */ + async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) { + throwErrorIfNamespaceSpecified(options); + return await this.client.closePointInTime(id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } } From 1fbea8cd78b4f520be1ea0cfa01bb2b6d2a4f060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 11 Feb 2021 21:03:43 +0100 Subject: [PATCH 23/26] [Logs UI] Use async search in the log stream page (#90303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Felix Stürmer Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/http_api/log_entries/entries.ts | 69 --- .../common/http_api/log_entries/highlights.ts | 36 +- .../common/http_api/log_entries/index.ts | 1 - .../components/log_stream/log_stream.tsx | 2 - .../scrollable_log_text_stream_view.tsx | 3 +- .../logs/log_entries/api/fetch_log_entries.ts | 26 - .../containers/logs/log_entries/index.ts | 455 ------------------ .../containers/logs/log_entries/types.ts | 76 --- .../logs/log_position/log_position_state.ts | 25 +- .../containers/logs/log_stream/index.ts | 141 ++++-- .../log_stream/use_fetch_log_entries_after.ts | 6 +- .../use_fetch_log_entries_around.ts | 11 +- .../use_fetch_log_entries_before.ts | 6 +- .../containers/logs/with_stream_items.ts | 55 --- .../pages/logs/stream/page_logs_content.tsx | 243 +++++++--- .../pages/logs/stream/page_providers.tsx | 37 +- .../data_search/use_data_search_request.ts | 4 +- x-pack/plugins/infra/server/infra_server.ts | 2 - .../log_entries_domain/log_entries_domain.ts | 6 +- .../server/routes/log_entries/entries.ts | 97 ---- .../infra/server/routes/log_entries/index.ts | 1 - .../log_entries/queries/log_entries.ts | 8 +- .../api_integration/apis/metrics_ui/index.js | 2 - .../apis/metrics_ui/log_entries.ts | 410 ---------------- .../apis/metrics_ui/logs_without_millis.ts | 130 ----- 25 files changed, 350 insertions(+), 1502 deletions(-) delete mode 100644 x-pack/plugins/infra/common/http_api/log_entries/entries.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/index.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/types.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/with_stream_items.ts delete mode 100644 x-pack/plugins/infra/server/routes/log_entries/entries.ts delete mode 100644 x-pack/test/api_integration/apis/metrics_ui/log_entries.ts delete mode 100644 x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts deleted file mode 100644 index e2207ef18c8f2e..00000000000000 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ /dev/null @@ -1,69 +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 * as rt from 'io-ts'; -import { logEntryCursorRT, logEntryRT } from '../../log_entry'; -import { logSourceColumnConfigurationRT } from '../log_sources'; - -export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; - -export const logEntriesBaseRequestRT = rt.intersection([ - rt.type({ - sourceId: rt.string, - startTimestamp: rt.number, - endTimestamp: rt.number, - }), - rt.partial({ - query: rt.union([rt.string, rt.null]), - size: rt.number, - columns: rt.array(logSourceColumnConfigurationRT), - }), -]); - -export const logEntriesBeforeRequestRT = rt.intersection([ - logEntriesBaseRequestRT, - rt.type({ before: rt.union([logEntryCursorRT, rt.literal('last')]) }), -]); - -export const logEntriesAfterRequestRT = rt.intersection([ - logEntriesBaseRequestRT, - rt.type({ after: rt.union([logEntryCursorRT, rt.literal('first')]) }), -]); - -export const logEntriesCenteredRequestRT = rt.intersection([ - logEntriesBaseRequestRT, - rt.type({ center: logEntryCursorRT }), -]); - -export const logEntriesRequestRT = rt.union([ - logEntriesBaseRequestRT, - logEntriesBeforeRequestRT, - logEntriesAfterRequestRT, - logEntriesCenteredRequestRT, -]); - -export type LogEntriesBaseRequest = rt.TypeOf; -export type LogEntriesBeforeRequest = rt.TypeOf; -export type LogEntriesAfterRequest = rt.TypeOf; -export type LogEntriesCenteredRequest = rt.TypeOf; -export type LogEntriesRequest = rt.TypeOf; - -export const logEntriesResponseRT = rt.type({ - data: rt.intersection([ - rt.type({ - entries: rt.array(logEntryRT), - topCursor: rt.union([logEntryCursorRT, rt.null]), - bottomCursor: rt.union([logEntryCursorRT, rt.null]), - }), - rt.partial({ - hasMoreBefore: rt.boolean, - hasMoreAfter: rt.boolean, - }), - ]), -}); - -export type LogEntriesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 7848295320b747..892abca32e7532 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -7,37 +7,37 @@ import * as rt from 'io-ts'; import { logEntryCursorRT, logEntryRT } from '../../log_entry'; -import { - logEntriesBaseRequestRT, - logEntriesBeforeRequestRT, - logEntriesAfterRequestRT, - logEntriesCenteredRequestRT, -} from './entries'; +import { logSourceColumnConfigurationRT } from '../log_sources'; export const LOG_ENTRIES_HIGHLIGHTS_PATH = '/api/log_entries/highlights'; -const highlightsRT = rt.type({ - highlightTerms: rt.array(rt.string), -}); - export const logEntriesHighlightsBaseRequestRT = rt.intersection([ - logEntriesBaseRequestRT, - highlightsRT, + rt.type({ + sourceId: rt.string, + startTimestamp: rt.number, + endTimestamp: rt.number, + highlightTerms: rt.array(rt.string), + }), + rt.partial({ + query: rt.union([rt.string, rt.null]), + size: rt.number, + columns: rt.array(logSourceColumnConfigurationRT), + }), ]); export const logEntriesHighlightsBeforeRequestRT = rt.intersection([ - logEntriesBeforeRequestRT, - highlightsRT, + logEntriesHighlightsBaseRequestRT, + rt.type({ before: rt.union([logEntryCursorRT, rt.literal('last')]) }), ]); export const logEntriesHighlightsAfterRequestRT = rt.intersection([ - logEntriesAfterRequestRT, - highlightsRT, + logEntriesHighlightsBaseRequestRT, + rt.type({ after: rt.union([logEntryCursorRT, rt.literal('first')]) }), ]); export const logEntriesHighlightsCenteredRequestRT = rt.intersection([ - logEntriesCenteredRequestRT, - highlightsRT, + logEntriesHighlightsBaseRequestRT, + rt.type({ center: logEntryCursorRT }), ]); export const logEntriesHighlightsRequestRT = rt.union([ diff --git a/x-pack/plugins/infra/common/http_api/log_entries/index.ts b/x-pack/plugins/infra/common/http_api/log_entries/index.ts index 34e15fc0747bec..83d240ca8f2731 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -export * from './entries'; export * from './highlights'; export * from './summary'; export * from './summary_highlights'; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index 35c17188af8ef8..9ab4ebf36b5f3e 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -177,10 +177,8 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re hasMoreBeforeStart={hasMoreBefore} hasMoreAfterEnd={hasMoreAfter} isStreaming={false} - lastLoadedTime={null} jumpToTarget={noop} reportVisibleInterval={handlePagination} - loadNewerItems={noop} reloadItems={fetchEntries} highlightedItem={highlight ?? null} currentHighlightKey={null} diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 785d44cd936f23..a12ebc4445ecc4 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -39,7 +39,7 @@ interface ScrollableLogTextStreamViewProps { hasMoreBeforeStart: boolean; hasMoreAfterEnd: boolean; isStreaming: boolean; - lastLoadedTime: Date | null; + lastLoadedTime?: Date; target: TimeKey | null; jumpToTarget: (target: TimeKey) => any; reportVisibleInterval: (params: { @@ -50,7 +50,6 @@ interface ScrollableLogTextStreamViewProps { endKey: TimeKey | null; fromScroll: boolean; }) => any; - loadNewerItems: () => void; reloadItems: () => void; onOpenLogEntryFlyout?: (logEntryId?: string) => void; setContextEntry?: (entry: LogEntry) => void; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts deleted file mode 100644 index ef4df80bd74f29..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts +++ /dev/null @@ -1,26 +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 type { HttpHandler } from 'src/core/public'; - -import { decodeOrThrow } from '../../../../../common/runtime_types'; - -import { - LOG_ENTRIES_PATH, - LogEntriesRequest, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../../../common/http_api'; - -export const fetchLogEntries = async (requestArgs: LogEntriesRequest, fetch: HttpHandler) => { - const response = await fetch(LOG_ENTRIES_PATH, { - method: 'POST', - body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)), - }); - - return decodeOrThrow(logEntriesResponseRT)(response); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts deleted file mode 100644 index a09eb6a29ecb27..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ /dev/null @@ -1,455 +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 { useEffect, useState, useReducer, useCallback } from 'react'; -import useMountedState from 'react-use/lib/useMountedState'; -import createContainer from 'constate'; -import { pick, throttle } from 'lodash'; -import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; -import { - LogEntriesResponse, - LogEntriesRequest, - LogEntriesBaseRequest, -} from '../../../../common/http_api'; -import { LogEntry } from '../../../../common/log_entry'; -import { fetchLogEntries } from './api/fetch_log_entries'; -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; - -const DESIRED_BUFFER_PAGES = 2; -const LIVE_STREAM_INTERVAL = 5000; - -enum Action { - FetchingNewEntries, - FetchingMoreEntries, - ReceiveNewEntries, - ReceiveEntriesBefore, - ReceiveEntriesAfter, - ErrorOnNewEntries, - ErrorOnMoreEntries, - ExpandRange, -} - -type ReceiveActions = - | Action.ReceiveNewEntries - | Action.ReceiveEntriesBefore - | Action.ReceiveEntriesAfter; - -interface ReceiveEntriesAction { - type: ReceiveActions; - payload: LogEntriesResponse['data']; -} -interface ExpandRangeAction { - type: Action.ExpandRange; - payload: { before: boolean; after: boolean }; -} -interface FetchOrErrorAction { - type: Exclude; -} -type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction; - -type Dispatch = (action: ActionObj) => void; - -interface LogEntriesProps { - startTimestamp: number; - endTimestamp: number; - timestampsLastUpdate: number; - filterQuery: string | null; - timeKey: TimeKey | null; - pagesBeforeStart: number | null; - pagesAfterEnd: number | null; - sourceId: string; - isStreaming: boolean; - jumpToTargetPosition: (position: TimeKey) => void; -} - -type FetchEntriesParams = Omit; -type FetchMoreEntriesParams = Pick; - -export interface LogEntriesStateParams { - entries: LogEntriesResponse['data']['entries']; - topCursor: LogEntriesResponse['data']['topCursor'] | null; - bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null; - centerCursor: TimeKey | null; - isReloading: boolean; - isLoadingMore: boolean; - lastLoadedTime: Date | null; - hasMoreBeforeStart: boolean; - hasMoreAfterEnd: boolean; -} - -export interface LogEntriesCallbacks { - fetchNewerEntries: () => Promise; - checkForNewEntries: () => Promise; -} -export const logEntriesInitialCallbacks = { - fetchNewerEntries: async () => {}, -}; - -export const logEntriesInitialState: LogEntriesStateParams = { - entries: [], - topCursor: null, - bottomCursor: null, - centerCursor: null, - isReloading: true, - isLoadingMore: false, - lastLoadedTime: null, - hasMoreBeforeStart: false, - hasMoreAfterEnd: false, -}; - -const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => { - const ids = new Set(entriesB.map((item) => item.id)); - return entriesA.filter((item) => !ids.has(item.id)); -}; - -const shouldFetchNewEntries = ({ - prevParams, - timeKey, - filterQuery, - topCursor, - bottomCursor, - startTimestamp, - endTimestamp, -}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => { - const shouldLoadWithNewDates = prevParams - ? (startTimestamp !== prevParams.startTimestamp && - startTimestamp > prevParams.startTimestamp) || - (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp) - : true; - const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true; - const shouldLoadAroundNewPosition = - timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey)); - - return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition; -}; - -enum ShouldFetchMoreEntries { - Before, - After, -} - -const shouldFetchMoreEntries = ( - { pagesAfterEnd, pagesBeforeStart }: FetchMoreEntriesParams, - { hasMoreBeforeStart, hasMoreAfterEnd }: LogEntriesStateParams -) => { - if (pagesBeforeStart === null || pagesAfterEnd === null) return false; - if (pagesBeforeStart < DESIRED_BUFFER_PAGES && hasMoreBeforeStart) - return ShouldFetchMoreEntries.Before; - if (pagesAfterEnd < DESIRED_BUFFER_PAGES && hasMoreAfterEnd) return ShouldFetchMoreEntries.After; - return false; -}; - -const useFetchEntriesEffect = ( - state: LogEntriesStateParams, - dispatch: Dispatch, - props: LogEntriesProps -) => { - const { services } = useKibanaContextForPlugin(); - const isMounted = useMountedState(); - const [prevParams, cachePrevParams] = useState(); - const [startedStreaming, setStartedStreaming] = useState(false); - const dispatchIfMounted = useCallback((action) => (isMounted() ? dispatch(action) : undefined), [ - dispatch, - isMounted, - ]); - - const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { - if (!props.startTimestamp || !props.endTimestamp) { - return; - } - - dispatchIfMounted({ type: Action.FetchingNewEntries }); - - try { - const commonFetchArgs: LogEntriesBaseRequest = { - sourceId: overrides.sourceId || props.sourceId, - startTimestamp: overrides.startTimestamp || props.startTimestamp, - endTimestamp: overrides.endTimestamp || props.endTimestamp, - query: overrides.filterQuery || props.filterQuery, - }; - - const fetchArgs: LogEntriesRequest = props.timeKey - ? { - ...commonFetchArgs, - center: props.timeKey, - } - : { - ...commonFetchArgs, - before: 'last', - }; - - const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - dispatchIfMounted({ type: Action.ReceiveNewEntries, payload }); - - // Move position to the bottom if it's the first load. - // Do it in the next tick to allow the `dispatch` to fire - if (!props.timeKey && payload.bottomCursor) { - setTimeout(() => { - if (isMounted()) { - props.jumpToTargetPosition(payload.bottomCursor!); - } - }); - } else if ( - props.timeKey && - payload.topCursor && - payload.bottomCursor && - !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey) - ) { - props.jumpToTargetPosition(payload.topCursor); - } - } catch (e) { - dispatchIfMounted({ type: Action.ErrorOnNewEntries }); - } - }; - - const runFetchMoreEntriesRequest = async ( - direction: ShouldFetchMoreEntries, - overrides: Partial = {} - ) => { - if (!props.startTimestamp || !props.endTimestamp) { - return; - } - const getEntriesBefore = direction === ShouldFetchMoreEntries.Before; - - // Control that cursors are correct - if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) { - return; - } - - dispatchIfMounted({ type: Action.FetchingMoreEntries }); - - try { - const commonFetchArgs: LogEntriesBaseRequest = { - sourceId: overrides.sourceId || props.sourceId, - startTimestamp: overrides.startTimestamp || props.startTimestamp, - endTimestamp: overrides.endTimestamp || props.endTimestamp, - query: overrides.filterQuery || props.filterQuery, - }; - - const fetchArgs: LogEntriesRequest = getEntriesBefore - ? { - ...commonFetchArgs, - before: state.topCursor!, // We already check for nullity above - } - : { - ...commonFetchArgs, - after: state.bottomCursor, - }; - - const { data: payload } = await fetchLogEntries(fetchArgs, services.http.fetch); - - dispatchIfMounted({ - type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, - payload, - }); - - return payload.bottomCursor; - } catch (e) { - dispatchIfMounted({ type: Action.ErrorOnMoreEntries }); - } - }; - - const fetchNewEntriesEffectDependencies = Object.values( - pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp']) - ); - const fetchNewEntriesEffect = () => { - if (props.isStreaming && prevParams) return; - if (shouldFetchNewEntries({ ...props, ...state, prevParams })) { - runFetchNewEntriesRequest(); - } - cachePrevParams(props); - }; - - const fetchMoreEntriesEffectDependencies = [ - ...Object.values(pick(props, ['pagesAfterEnd', 'pagesBeforeStart'])), - Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])), - ]; - const fetchMoreEntriesEffect = () => { - if (state.isLoadingMore || props.isStreaming) return; - const direction = shouldFetchMoreEntries(props, state); - switch (direction) { - case ShouldFetchMoreEntries.Before: - case ShouldFetchMoreEntries.After: - runFetchMoreEntriesRequest(direction); - break; - default: - break; - } - }; - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const fetchNewerEntries = useCallback( - throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500), - [props, state.bottomCursor] - ); - - const streamEntriesEffectDependencies = [ - props.isStreaming, - state.isLoadingMore, - state.isReloading, - ]; - const streamEntriesEffect = () => { - (async () => { - if (props.isStreaming && !state.isLoadingMore && !state.isReloading) { - const endTimestamp = Date.now(); - if (startedStreaming) { - await new Promise((res) => setTimeout(res, LIVE_STREAM_INTERVAL)); - } else { - props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp }); - setStartedStreaming(true); - if (state.hasMoreAfterEnd) { - runFetchNewEntriesRequest({ endTimestamp }); - return; - } - } - const newEntriesEnd = await runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After, { - endTimestamp, - }); - if (newEntriesEnd) { - props.jumpToTargetPosition(newEntriesEnd); - } - } else if (!props.isStreaming) { - setStartedStreaming(false); - } - })(); - }; - - const expandRangeEffect = () => { - if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) { - return; - } - - if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) { - return; - } - - const shouldExpand = { - before: props.startTimestamp < prevParams.startTimestamp, - after: props.endTimestamp > prevParams.endTimestamp, - }; - - dispatchIfMounted({ type: Action.ExpandRange, payload: shouldExpand }); - }; - - const expandRangeEffectDependencies = [ - prevParams?.startTimestamp, - prevParams?.endTimestamp, - props.startTimestamp, - props.endTimestamp, - props.timestampsLastUpdate, - ]; - - /* eslint-disable react-hooks/exhaustive-deps */ - useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies); - useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies); - useEffect(streamEntriesEffect, streamEntriesEffectDependencies); - useEffect(expandRangeEffect, expandRangeEffectDependencies); - /* eslint-enable react-hooks/exhaustive-deps */ - - return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest }; -}; - -export const useLogEntriesState: ( - props: LogEntriesProps -) => [LogEntriesStateParams, LogEntriesCallbacks] = (props) => { - const [state, dispatch] = useReducer(logEntriesStateReducer, logEntriesInitialState); - - const { fetchNewerEntries, checkForNewEntries } = useFetchEntriesEffect(state, dispatch, props); - const callbacks = { fetchNewerEntries, checkForNewEntries }; - - return [state, callbacks]; -}; - -const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => { - switch (action.type) { - case Action.ReceiveNewEntries: - return { - ...prevState, - entries: action.payload.entries, - topCursor: action.payload.topCursor, - bottomCursor: action.payload.bottomCursor, - centerCursor: getCenterCursor(action.payload.entries), - lastLoadedTime: new Date(), - isReloading: false, - hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, - hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, - }; - - case Action.ReceiveEntriesBefore: { - const newEntries = action.payload.entries; - const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); - const entries = [...newEntries, ...prevEntries]; - - const update = { - entries, - isLoadingMore: false, - hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, - // Keep the previous cursor if request comes empty, to easily extend the range. - topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, - centerCursor: getCenterCursor(entries), - lastLoadedTime: new Date(), - }; - - return { ...prevState, ...update }; - } - case Action.ReceiveEntriesAfter: { - const newEntries = action.payload.entries; - const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); - const entries = [...prevEntries, ...newEntries]; - - const update = { - entries, - isLoadingMore: false, - hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, - // Keep the previous cursor if request comes empty, to easily extend the range. - bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, - centerCursor: getCenterCursor(entries), - lastLoadedTime: new Date(), - }; - - return { ...prevState, ...update }; - } - case Action.FetchingNewEntries: - return { - ...prevState, - isReloading: true, - entries: [], - topCursor: null, - bottomCursor: null, - centerCursor: null, - // Assume there are more pages on both ends unless proven wrong by the - // API with an explicit `false` response. - hasMoreBeforeStart: true, - hasMoreAfterEnd: true, - }; - case Action.FetchingMoreEntries: - return { ...prevState, isLoadingMore: true }; - case Action.ErrorOnNewEntries: - return { ...prevState, isReloading: false }; - case Action.ErrorOnMoreEntries: - return { ...prevState, isLoadingMore: false }; - - case Action.ExpandRange: { - const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart; - const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd; - - return { - ...prevState, - hasMoreBeforeStart, - hasMoreAfterEnd, - }; - } - default: - throw new Error(); - } -}; - -function getCenterCursor(entries: LogEntry[]): TimeKey | null { - return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null; -} - -export const LogEntriesState = createContainer(useLogEntriesState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/types.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/types.ts deleted file mode 100644 index ec62d7588ac654..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/types.ts +++ /dev/null @@ -1,76 +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. - */ - -/** A segment of the log entry message that was derived from a field */ -export interface InfraLogMessageFieldSegment { - /** The field the segment was derived from */ - field: string; - /** The segment's message */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} -/** A segment of the log entry message that was derived from a string literal */ -export interface InfraLogMessageConstantSegment { - /** The segment's message */ - constant: string; -} - -export type InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment; - -/** A special built-in column that contains the log entry's timestamp */ -export interface InfraLogEntryTimestampColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The timestamp */ - timestamp: number; -} -/** A special built-in column that contains the log entry's constructed message */ -export interface InfraLogEntryMessageColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** A list of the formatted log entry segments */ - message: InfraLogMessageSegment[]; -} - -/** A column that contains the value of a field of the log entry */ -export interface InfraLogEntryFieldColumn { - /** The id of the corresponding column configuration */ - columnId: string; - /** The field name of the column */ - field: string; - /** The value of the field in the log entry */ - value: string; - /** A list of highlighted substrings of the value */ - highlights: string[]; -} - -/** A column of a log entry */ -export type InfraLogEntryColumn = - | InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn; - -/** A representation of the log entry's position in the event stream */ -export interface InfraTimeKey { - /** The timestamp of the event that the log entry corresponds to */ - time: number; - /** The tiebreaker that disambiguates events with the same timestamp */ - tiebreaker: number; -} - -/** A log entry */ -export interface InfraLogEntry { - /** A unique representation of the log entry's position in the event stream */ - key: InfraTimeKey; - /** The log entry's id */ - gid: string; - /** The source id */ - source: string; - /** The columns used for rendering the log entry */ - columns: InfraLogEntryColumn[]; -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index bf1192956e46e3..56f64b012fa062 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -8,6 +8,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import createContainer from 'constate'; import useSetState from 'react-use/lib/useSetState'; +import useInterval from 'react-use/lib/useInterval'; import { TimeKey } from '../../../../common/time'; import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; import { useKibanaTimefilterTime } from '../../../hooks/use_kibana_timefilter_time'; @@ -82,6 +83,7 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN }; const TIME_DEFAULTS = { from: 'now-1d', to: 'now' }; +const STREAMING_INTERVAL = 5000; export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { const [getTime, setTime] = useKibanaTimefilterTime(TIME_DEFAULTS); @@ -194,6 +196,21 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall } }, [dateRange.endDateExpression, visiblePositions, setDateRange]); + const startLiveStreaming = useCallback(() => { + setIsStreaming(true); + jumpToTargetPosition(null); + updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); + }, [updateDateRange]); + + const stopLiveStreaming = useCallback(() => { + setIsStreaming(false); + }, []); + + useInterval( + () => updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }), + isStreaming ? STREAMING_INTERVAL : null + ); + const state = { isInitialized, targetPosition, @@ -215,12 +232,8 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall [jumpToTargetPosition] ), reportVisiblePositions, - startLiveStreaming: useCallback(() => { - setIsStreaming(true); - jumpToTargetPosition(null); - updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); - }, [setIsStreaming, updateDateRange]), - stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]), + startLiveStreaming, + stopLiveStreaming, updateDateRange, }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 43c231d0ea440a..53b544e7e43705 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; +import createContainer from 'constate'; import usePrevious from 'react-use/lib/usePrevious'; import useSetState from 'react-use/lib/useSetState'; import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; @@ -31,8 +32,11 @@ interface LogStreamState { bottomCursor: LogEntryCursor | null; hasMoreBefore: boolean; hasMoreAfter: boolean; + lastLoadedTime?: Date; } +type FetchPageCallback = (params?: { force?: boolean; extendTo?: number }) => void; + const INITIAL_STATE: LogStreamState = { entries: [], topCursor: null, @@ -53,6 +57,7 @@ export function useLogStream({ columns, }: LogStreamProps) { const [state, setState] = useSetState(INITIAL_STATE); + const [resetOnSuccess, setResetOnSuccess] = useState(false); // Ensure the pagination keeps working when the timerange gets extended const prevStartTimestamp = usePrevious(startTimestamp); @@ -104,14 +109,21 @@ export function useLogStream({ useSubscription(logEntriesAroundSearchResponses$, { next: ({ before, after, combined }) => { if ((before.response.data != null || after?.response.data != null) && !combined.isPartial) { - setState((prevState) => ({ - ...prevState, - entries: combined.entries, - hasMoreAfter: combined.hasMoreAfter ?? prevState.hasMoreAfter, - hasMoreBefore: combined.hasMoreAfter ?? prevState.hasMoreAfter, - bottomCursor: combined.bottomCursor, - topCursor: combined.topCursor, - })); + setState((_prevState) => { + const prevState = resetOnSuccess ? INITIAL_STATE : _prevState; + return { + ...(resetOnSuccess ? INITIAL_STATE : prevState), + entries: combined.entries, + hasMoreAfter: combined.hasMoreAfter ?? prevState.hasMoreAfter, + hasMoreBefore: combined.hasMoreAfter ?? prevState.hasMoreAfter, + bottomCursor: combined.bottomCursor, + topCursor: combined.topCursor, + lastLoadedTime: new Date(), + }; + }); + if (resetOnSuccess) { + setResetOnSuccess(false); + } } }, }); @@ -125,30 +137,43 @@ export function useLogStream({ useSubscription(logEntriesBeforeSearchResponse$, { next: ({ response: { data, isPartial } }) => { if (data != null && !isPartial) { - setState((prevState) => ({ - ...prevState, - entries: [...data.entries, ...prevState.entries], - hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, - topCursor: data.topCursor ?? prevState.topCursor, - bottomCursor: prevState.bottomCursor ?? data.bottomCursor, - })); + setState((_prevState) => { + const prevState = resetOnSuccess ? INITIAL_STATE : _prevState; + return { + ...(resetOnSuccess ? INITIAL_STATE : prevState), + entries: [...data.entries, ...prevState.entries], + hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, + topCursor: data.topCursor ?? prevState.topCursor, + bottomCursor: prevState.bottomCursor ?? data.bottomCursor, + lastLoadedTime: new Date(), + }; + }); + if (resetOnSuccess) { + setResetOnSuccess(false); + } } }, }); - const fetchPreviousEntries = useCallback(() => { - if (state.topCursor === null) { - throw new Error( - 'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' - ); - } + const fetchPreviousEntries = useCallback( + (params) => { + if (state.topCursor === null) { + throw new Error( + 'useLogStream: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } - if (!state.hasMoreBefore) { - return; - } + if (!state.hasMoreBefore && !params?.force) { + return; + } - fetchLogEntriesBefore(state.topCursor, LOG_ENTRIES_CHUNK_SIZE); - }, [fetchLogEntriesBefore, state.topCursor, state.hasMoreBefore]); + fetchLogEntriesBefore(state.topCursor, { + size: LOG_ENTRIES_CHUNK_SIZE, + extendTo: params?.extendTo, + }); + }, + [fetchLogEntriesBefore, state.topCursor, state.hasMoreBefore] + ); const { fetchLogEntriesAfter, @@ -159,30 +184,43 @@ export function useLogStream({ useSubscription(logEntriesAfterSearchResponse$, { next: ({ response: { data, isPartial } }) => { if (data != null && !isPartial) { - setState((prevState) => ({ - ...prevState, - entries: [...prevState.entries, ...data.entries], - hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, - topCursor: prevState.topCursor ?? data.topCursor, - bottomCursor: data.bottomCursor ?? prevState.bottomCursor, - })); + setState((_prevState) => { + const prevState = resetOnSuccess ? INITIAL_STATE : _prevState; + return { + ...(resetOnSuccess ? INITIAL_STATE : prevState), + entries: [...prevState.entries, ...data.entries], + hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + topCursor: prevState.topCursor ?? data.topCursor, + bottomCursor: data.bottomCursor ?? prevState.bottomCursor, + lastLoadedTime: new Date(), + }; + }); + if (resetOnSuccess) { + setResetOnSuccess(false); + } } }, }); - const fetchNextEntries = useCallback(() => { - if (state.bottomCursor === null) { - throw new Error( - 'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' - ); - } + const fetchNextEntries = useCallback( + (params) => { + if (state.bottomCursor === null) { + throw new Error( + 'useLogStream: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } - if (!state.hasMoreAfter) { - return; - } + if (!state.hasMoreAfter && !params?.force) { + return; + } - fetchLogEntriesAfter(state.bottomCursor, LOG_ENTRIES_CHUNK_SIZE); - }, [fetchLogEntriesAfter, state.bottomCursor, state.hasMoreAfter]); + fetchLogEntriesAfter(state.bottomCursor, { + size: LOG_ENTRIES_CHUNK_SIZE, + extendTo: params?.extendTo, + }); + }, + [fetchLogEntriesAfter, state.bottomCursor, state.hasMoreAfter] + ); const fetchEntries = useCallback(() => { setState(INITIAL_STATE); @@ -190,10 +228,18 @@ export function useLogStream({ if (center) { fetchLogEntriesAround(center, LOG_ENTRIES_CHUNK_SIZE); } else { - fetchLogEntriesBefore('last', LOG_ENTRIES_CHUNK_SIZE); + fetchLogEntriesBefore('last', { size: LOG_ENTRIES_CHUNK_SIZE }); } }, [center, fetchLogEntriesAround, fetchLogEntriesBefore, setState]); + // Specialized version of `fetchEntries` for streaming. + // - Reset the entries _after_ the network request succeeds. + // - Ignores `center`. + const fetchNewestEntries = useCallback(() => { + setResetOnSuccess(true); + fetchLogEntriesBefore('last', { size: LOG_ENTRIES_CHUNK_SIZE }); + }, [fetchLogEntriesBefore]); + const isReloading = useMemo( () => isLogEntriesAroundRequestRunning || @@ -216,7 +262,10 @@ export function useLogStream({ fetchEntries, fetchNextEntries, fetchPreviousEntries, + fetchNewestEntries, isLoadingMore, isReloading, }; } + +export const [LogStreamProvider, useLogStreamContext] = createContainer(useLogStream); diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts index 2609bd88f4cc2f..2bb67f91c468b2 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_after.ts @@ -46,17 +46,17 @@ export const useLogEntriesAfterRequest = ({ const { search: fetchLogEntriesAfter, requests$: logEntriesAfterSearchRequests$ } = useDataSearch( { getRequest: useCallback( - (cursor: LogEntryAfterCursor['after'], size: number) => { + (cursor: LogEntryAfterCursor['after'], params: { size: number; extendTo?: number }) => { return !!sourceId ? { request: { params: logEntriesSearchRequestParamsRT.encode({ after: cursor, columns: columnOverrides, - endTimestamp, + endTimestamp: params?.extendTo ?? endTimestamp, highlightPhrase, query, - size, + size: params.size, sourceId, startTimestamp, }), diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts index a2c273abc450c4..d96cb7f2b713aa 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_around.ts @@ -59,7 +59,9 @@ export const useFetchLogEntriesAround = ({ const fetchLogEntriesAround = useCallback( (cursor: LogEntryCursor, size: number) => { - const logEntriesBeforeSearchRequest = fetchLogEntriesBefore(cursor, Math.floor(size / 2)); + const logEntriesBeforeSearchRequest = fetchLogEntriesBefore(cursor, { + size: Math.floor(size / 2), + }); if (logEntriesBeforeSearchRequest == null) { return; @@ -75,10 +77,9 @@ export const useFetchLogEntriesAround = ({ tiebreaker: 0, }; - const logEntriesAfterSearchRequest = fetchLogEntriesAfter( - cursorAfter, - Math.ceil(size / 2) - ); + const logEntriesAfterSearchRequest = fetchLogEntriesAfter(cursorAfter, { + size: Math.ceil(size / 2), + }); if (logEntriesAfterSearchRequest == null) { throw new Error('Failed to create request: no request args given'); diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts index acf80552ce694c..c1722d27cd343b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/use_fetch_log_entries_before.ts @@ -48,7 +48,7 @@ export const useLogEntriesBeforeRequest = ({ requests$: logEntriesBeforeSearchRequests$, } = useDataSearch({ getRequest: useCallback( - (cursor: LogEntryBeforeCursor['before'], size: number) => { + (cursor: LogEntryBeforeCursor['before'], params: { size: number; extendTo?: number }) => { return !!sourceId ? { request: { @@ -58,9 +58,9 @@ export const useLogEntriesBeforeRequest = ({ endTimestamp, highlightPhrase, query, - size, + size: params.size, sourceId, - startTimestamp, + startTimestamp: params.extendTo ?? startTimestamp, }), }, options: { strategy: LOG_ENTRIES_SEARCH_STRATEGY }, diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts deleted file mode 100644 index 127569d65fa247..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ /dev/null @@ -1,55 +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 { useContext, useMemo } from 'react'; -import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item'; -import { RendererFunction } from '../../utils/typed_react'; -// deep inporting to avoid a circular import problem -import { LogHighlightsState } from './log_highlights/log_highlights'; -import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; -import { UniqueTimeKey } from '../../../common/time'; -import { LogEntry } from '../../../common/log_entry'; - -export const WithStreamItems: React.FunctionComponent<{ - children: RendererFunction< - LogEntriesStateParams & - LogEntriesCallbacks & { - currentHighlightKey: UniqueTimeKey | null; - items: StreamItem[]; - } - >; -}> = ({ children }) => { - const [logEntries, logEntriesCallbacks] = useContext(LogEntriesState.Context); - const { currentHighlightKey, logEntryHighlightsById } = useContext(LogHighlightsState.Context); - - const items = useMemo( - () => - logEntries.isReloading - ? [] - : logEntries.entries.map((logEntry) => - createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) - ), - - [logEntries.entries, logEntries.isReloading, logEntryHighlightsById] - ); - - return children({ - ...logEntries, - ...logEntriesCallbacks, - items, - currentHighlightKey, - }); -}; - -const createLogEntryStreamItem = ( - logEntry: LogEntry, - highlights: LogEntry[] -): LogEntryStreamItem => ({ - kind: 'logEntry' as 'logEntry', - logEntry, - highlights, -}); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx index 1744d83a4c98f8..e3e576a22e6fb7 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx @@ -5,12 +5,15 @@ * 2.0. */ -import React, { useContext, useCallback } from 'react'; +import React, { useContext, useCallback, useMemo, useEffect } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; +import { LogEntry } from '../../../../common/log_entry'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { AutoSizer } from '../../../components/auto_sizer'; import { LogEntryFlyout } from '../../../components/logging/log_entry_flyout'; import { LogMinimap } from '../../../components/logging/log_minimap'; import { ScrollableLogTextStreamView } from '../../../components/logging/log_text_stream'; +import { LogEntryStreamItem } from '../../../components/logging/log_text_stream/item'; import { PageContent } from '../../../components/page'; import { LogFilterState } from '../../../containers/logs/log_filter'; import { @@ -24,9 +27,12 @@ import { WithSummary } from '../../../containers/logs/log_summary'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; import { WithLogTextviewUrlState } from '../../../containers/logs/with_log_textview'; -import { WithStreamItems } from '../../../containers/logs/with_stream_items'; import { LogsToolbar } from './page_toolbar'; import { PageViewLogInContext } from './page_view_log_in_context'; +import { useLogStreamContext } from '../../../containers/logs/log_stream'; +import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; + +const PAGE_THRESHOLD = 2; export const LogsPageLogsContent: React.FunctionComponent = () => { const { sourceConfiguration, sourceId } = useLogSourceContext(); @@ -39,9 +45,10 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { isFlyoutOpen, logEntryId: flyoutLogEntryId, } = useLogEntryFlyoutContext(); - const { logSummaryHighlights } = useContext(LogHighlightsState.Context); - const { applyLogFilterQuery } = useContext(LogFilterState.Context); + const { + startTimestamp, + endTimestamp, isStreaming, targetPosition, visibleMidpointTime, @@ -54,9 +61,131 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { endDateExpression, updateDateRange, } = useContext(LogPositionState.Context); + const { filterQuery, applyLogFilterQuery } = useContext(LogFilterState.Context); + + const { + isReloading, + entries, + topCursor, + bottomCursor, + hasMoreAfter: hasMoreAfterEnd, + hasMoreBefore: hasMoreBeforeStart, + isLoadingMore, + lastLoadedTime, + fetchEntries, + fetchPreviousEntries, + fetchNextEntries, + fetchNewestEntries, + } = useLogStreamContext(); + + const prevStartTimestamp = usePrevious(startTimestamp); + const prevEndTimestamp = usePrevious(endTimestamp); + const prevFilterQuery = usePrevious(filterQuery); + + // Refetch entries if... + useEffect(() => { + const isFirstLoad = !prevStartTimestamp || !prevEndTimestamp; + + const newDateRangeDoesNotOverlap = + (prevStartTimestamp != null && + startTimestamp != null && + prevStartTimestamp < startTimestamp) || + (prevEndTimestamp != null && endTimestamp != null && prevEndTimestamp > endTimestamp); + + const isCenterPointOutsideLoadedRange = + targetPosition != null && + ((topCursor != null && targetPosition.time < topCursor.time) || + (bottomCursor != null && targetPosition.time > bottomCursor.time)); + + const hasQueryChanged = filterQuery !== prevFilterQuery; + + if ( + isFirstLoad || + newDateRangeDoesNotOverlap || + isCenterPointOutsideLoadedRange || + hasQueryChanged + ) { + if (isStreaming) { + fetchNewestEntries(); + } else { + fetchEntries(); + } + } + }, [ + fetchEntries, + fetchNewestEntries, + isStreaming, + prevStartTimestamp, + prevEndTimestamp, + startTimestamp, + endTimestamp, + targetPosition, + topCursor, + bottomCursor, + filterQuery, + prevFilterQuery, + ]); + + const { logSummaryHighlights, currentHighlightKey, logEntryHighlightsById } = useContext( + LogHighlightsState.Context + ); + + const items = useMemo( + () => + isReloading + ? [] + : entries.map((logEntry) => + createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) + ), + + [entries, isReloading, logEntryHighlightsById] + ); const [, { setContextEntry }] = useContext(ViewLogInContext.Context); + const handleDateRangeExtension = useCallback( + (newDateRange) => { + updateDateRange(newDateRange); + + if ( + 'startDateExpression' in newDateRange && + isValidDatemath(newDateRange.startDateExpression) + ) { + fetchPreviousEntries({ + force: true, + extendTo: datemathToEpochMillis(newDateRange.startDateExpression)!, + }); + } + if ('endDateExpression' in newDateRange && isValidDatemath(newDateRange.endDateExpression)) { + fetchNextEntries({ + force: true, + extendTo: datemathToEpochMillis(newDateRange.endDateExpression)!, + }); + } + }, + [updateDateRange, fetchPreviousEntries, fetchNextEntries] + ); + + const handlePagination = useCallback( + (params) => { + reportVisiblePositions(params); + if (!params.fromScroll) { + return; + } + + if (isLoadingMore) { + return; + } + + if (params.pagesBeforeStart < PAGE_THRESHOLD) { + fetchPreviousEntries(); + } else if (params.pagesAfterEnd < PAGE_THRESHOLD) { + fetchNextEntries(); + } + }, + [reportVisiblePositions, isLoadingMore, fetchPreviousEntries, fetchNextEntries] + ); + const setFilter = useCallback( (filter, flyoutItemId, timeKey) => { applyLogFilterQuery(filter); @@ -84,47 +213,32 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { /> ) : null} - - {({ - currentHighlightKey, - hasMoreAfterEnd, - hasMoreBeforeStart, - isLoadingMore, - isReloading, - items, - lastLoadedTime, - fetchNewerEntries, - checkForNewEntries, - }) => ( - - )} - + {({ measureRef, bounds: { height = 0 }, content: { width = 0 } }) => { @@ -132,23 +246,19 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { {({ buckets, start, end }) => ( - - {({ isReloading }) => ( - 0 ? logSummaryHighlights[0].buckets : [] - } - target={visibleMidpointTime} - /> - )} - + 0 ? logSummaryHighlights[0].buckets : [] + } + target={visibleMidpointTime} + /> )} @@ -168,3 +278,12 @@ const LogPageMinimapColumn = euiStyled.div` display: flex; flex-direction: column; `; + +const createLogEntryStreamItem = ( + logEntry: LogEntry, + highlights: LogEntry[] +): LogEntryStreamItem => ({ + kind: 'logEntry' as 'logEntry', + logEntry, + highlights, +}); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index c69a39e8a7cf3d..d987cbeb439cc8 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -12,9 +12,9 @@ import { LogViewConfiguration } from '../../../containers/logs/log_view_configur import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_highlights'; import { LogPositionState, WithLogPositionUrlState } from '../../../containers/logs/log_position'; import { LogFilterState, WithLogFilterUrlState } from '../../../containers/logs/log_filter'; -import { LogEntriesState } from '../../../containers/logs/log_entries'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; +import { LogStreamProvider, useLogStreamContext } from '../../../containers/logs/log_stream'; const LogFilterStateProvider: React.FC = ({ children }) => { const { derivedIndexPattern } = useLogSourceContext(); @@ -47,35 +47,22 @@ const ViewLogInContextProvider: React.FC = ({ children }) => { const LogEntriesStateProvider: React.FC = ({ children }) => { const { sourceId } = useLogSourceContext(); - const { - startTimestamp, - endTimestamp, - timestampsLastUpdate, - targetPosition, - pagesBeforeStart, - pagesAfterEnd, - isStreaming, - jumpToTargetPosition, - isInitialized, - } = useContext(LogPositionState.Context); - const { filterQuery } = useContext(LogFilterState.Context); + const { startTimestamp, endTimestamp, targetPosition, isInitialized } = useContext( + LogPositionState.Context + ); + const { filterQueryAsKuery } = useContext(LogFilterState.Context); // Don't render anything if the date range is incorrect. if (!startTimestamp || !endTimestamp) { return null; } - const entriesProps = { + const logStreamProps = { + sourceId, startTimestamp, endTimestamp, - timestampsLastUpdate, - timeKey: targetPosition, - pagesBeforeStart, - pagesAfterEnd, - filterQuery, - sourceId, - isStreaming, - jumpToTargetPosition, + query: filterQueryAsKuery?.expression ?? undefined, + center: targetPosition ?? undefined, }; // Don't initialize the entries until the position has been fully intialized. @@ -84,12 +71,12 @@ const LogEntriesStateProvider: React.FC = ({ children }) => { return null; } - return {children}; + return {children}; }; const LogHighlightsStateProvider: React.FC = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); - const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context); + const { topCursor, bottomCursor, entries } = useLogStreamContext(); const { filterQuery } = useContext(LogFilterState.Context); const highlightsProps = { @@ -97,7 +84,7 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => { sourceVersion: sourceConfiguration?.version, entriesStart: topCursor, entriesEnd: bottomCursor, - centerCursor, + centerCursor: entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null, size: entries.length, filterQuery, }; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts index 6346f6305d99c6..f2dd5b9e87c93c 100644 --- a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts @@ -6,7 +6,7 @@ */ import { useCallback } from 'react'; -import { OperatorFunction, Subject } from 'rxjs'; +import { OperatorFunction, ReplaySubject } from 'rxjs'; import { share, tap } from 'rxjs/operators'; import { IKibanaSearchRequest, @@ -47,7 +47,7 @@ export const useDataSearch = < }) => { const { services } = useKibanaContextForPlugin(); const requests$ = useObservable( - () => new Subject>(), + () => new ReplaySubject>(1), [] ); diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 8a6f22d55750ed..69595c90c79114 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -26,7 +26,6 @@ import { initMetadataRoute } from './routes/metadata'; import { initSnapshotRoute } from './routes/snapshot'; import { initNodeDetailsRoute } from './routes/node_details'; import { - initLogEntriesRoute, initLogEntriesHighlightsRoute, initLogEntriesSummaryRoute, initLogEntriesSummaryHighlightsRoute, @@ -54,7 +53,6 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initSourceRoute(libs); initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); - initLogEntriesRoute(libs); initGetLogEntryExamplesRoute(libs); initLogEntriesHighlightsRoute(libs); initLogEntriesSummaryRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index f4f0a2a3c15d6a..e3c42c4dceede7 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -11,8 +11,8 @@ import type { InfraPluginRequestHandlerContext } from '../../../types'; import { LogEntriesSummaryBucket, LogEntriesSummaryHighlightsBucket, - LogEntriesRequest, } from '../../../../common/http_api'; +import { LogSourceColumnConfiguration } from '../../../../common/http_api/log_sources'; import { LogColumn, LogEntryCursor, LogEntry } from '../../../../common/log_entry'; import { InfraSourceConfiguration, @@ -71,7 +71,7 @@ export class InfraLogEntriesDomain { requestContext: InfraPluginRequestHandlerContext, sourceId: string, params: LogEntriesAroundParams, - columnOverrides?: LogEntriesRequest['columns'] + columnOverrides?: LogSourceColumnConfiguration[] ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; @@ -131,7 +131,7 @@ export class InfraLogEntriesDomain { requestContext: InfraPluginRequestHandlerContext, sourceId: string, params: LogEntriesParams, - columnOverrides?: LogEntriesRequest['columns'] + columnOverrides?: LogSourceColumnConfiguration[] ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { configuration } = await this.libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts deleted file mode 100644 index 8732b80e517a37..00000000000000 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ /dev/null @@ -1,97 +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 { createValidationFunction } from '../../../common/runtime_types'; - -import { InfraBackendLibs } from '../../lib/infra_types'; -import { - LOG_ENTRIES_PATH, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../common/http_api/log_entries'; -import { parseFilterQuery } from '../../utils/serialized_query'; -import { LogEntriesParams } from '../../lib/domains/log_entries_domain'; - -export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) => { - framework.registerRoute( - { - method: 'post', - path: LOG_ENTRIES_PATH, - validate: { body: createValidationFunction(logEntriesRequestRT) }, - }, - async (requestContext, request, response) => { - try { - const payload = request.body; - const { - startTimestamp: startTimestamp, - endTimestamp: endTimestamp, - sourceId, - query, - size, - columns, - } = payload; - - let entries; - let hasMoreBefore; - let hasMoreAfter; - - if ('center' in payload) { - ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntriesAround( - requestContext, - sourceId, - { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - center: payload.center, - size, - }, - columns - )); - } else { - let cursor: LogEntriesParams['cursor']; - if ('before' in payload) { - cursor = { before: payload.before }; - } else if ('after' in payload) { - cursor = { after: payload.after }; - } - - ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntries( - requestContext, - sourceId, - { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - cursor, - size, - }, - columns - )); - } - - const hasEntries = entries.length > 0; - - return response.ok({ - body: logEntriesResponseRT.encode({ - data: { - entries, - topCursor: hasEntries ? entries[0].cursor : null, - bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, - hasMoreBefore, - hasMoreAfter, - }, - }), - }); - } catch (error) { - return response.internalError({ - body: error.message, - }); - } - } - ); -}; diff --git a/x-pack/plugins/infra/server/routes/log_entries/index.ts b/x-pack/plugins/infra/server/routes/log_entries/index.ts index 34e15fc0747bec..83d240ca8f2731 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/index.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -export * from './entries'; export * from './highlights'; export * from './summary'; export * from './summary_highlights'; diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts index 460703b22766f1..613469fe758167 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entries.ts @@ -20,6 +20,8 @@ import { } from '../../../utils/elasticsearch_runtime_types'; import { createSortClause, createTimeRangeFilterClauses } from './common'; +const CONTEXT_FIELDS = ['log.file.path', 'host.name', 'container.id']; + export const createGetLogEntriesQuery = ( logEntriesIndex: string, startTimestamp: number, @@ -34,6 +36,7 @@ export const createGetLogEntriesQuery = ( ): RequestParams.AsyncSearchSubmit> => { const sortDirection = getSortDirection(cursor); const highlightQuery = createHighlightQuery(highlightTerm, fields); + const fieldsWithContext = createFieldsWithContext(fields); return { index: logEntriesIndex, @@ -51,7 +54,7 @@ export const createGetLogEntriesQuery = ( ], }, }, - fields, + fields: fieldsWithContext, _source: false, ...createSortClause(sortDirection, timestampField, tiebreakerField), ...createSearchAfterClause(cursor), @@ -117,6 +120,9 @@ const createHighlightQuery = ( } }; +const createFieldsWithContext = (fields: string[]): string[] => + Array.from(new Set([...fields, ...CONTEXT_FIELDS])); + export const logEntryHitRT = rt.intersection([ commonHitFieldsRT, rt.type({ diff --git a/x-pack/test/api_integration/apis/metrics_ui/index.js b/x-pack/test/api_integration/apis/metrics_ui/index.js index 34ad92e6b89a61..861d82733a0fad 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/index.js +++ b/x-pack/test/api_integration/apis/metrics_ui/index.js @@ -8,9 +8,7 @@ export default function ({ loadTestFile }) { describe('MetricsUI Endpoints', () => { loadTestFile(require.resolve('./metadata')); - loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./logs_without_millis')); loadTestFile(require.resolve('./log_sources')); loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts deleted file mode 100644 index 7299c3ff31b22b..00000000000000 --- a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts +++ /dev/null @@ -1,410 +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 expect from '@kbn/expect'; -import { v4 as uuidv4 } from 'uuid'; -import { - LOG_ENTRIES_PATH, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../../plugins/infra/common/http_api'; -import { - LogFieldColumn, - LogMessageColumn, - LogTimestampColumn, -} from '../../../../plugins/infra/common/log_entry'; -import { decodeOrThrow } from '../../../../plugins/infra/common/runtime_types'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2018-10-17T19:50:00.000Z').valueOf(), - tiebreaker: 0, -}; -const EARLIEST_KEY_WITH_DATA = { - time: new Date('2018-10-17T19:42:22.000Z').valueOf(), - tiebreaker: 5497614, -}; -const LATEST_KEY_WITH_DATA = { - time: new Date('2018-10-17T19:57:21.611Z').valueOf(), - tiebreaker: 5603910, -}; - -const COMMON_HEADERS = { - 'kbn-xsrf': 'some-xsrf-token', -}; - -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - const sourceConfigurationService = getService('infraOpsSourceConfiguration'); - - describe('log entry apis', () => { - before(() => esArchiver.load('infra/metrics_and_logs')); - after(() => esArchiver.unload('infra/metrics_and_logs')); - - describe('/log_entries/entries', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('works', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const firstEntry = entries[0]; - const lastEntry = entries[entries.length - 1]; - - // Has the default page size - expect(entries).to.have.length(200); - - // Cursors are set correctly - expect(firstEntry.cursor).to.eql(logEntriesResponse.data.topCursor); - expect(lastEntry.cursor).to.eql(logEntriesResponse.data.bottomCursor); - - // Entries fall within range - // @kbn/expect doesn't have a `lessOrEqualThan` or `moreOrEqualThan` comparators - expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); - expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); - }); - - it('Returns the default columns', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.columns).to.have.length(3); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const eventDatasetColumn = entry.columns[1] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[2] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - - it('Returns custom column configurations', async () => { - const customColumns = [ - { timestampColumn: { id: uuidv4() } }, - { fieldColumn: { id: uuidv4(), field: 'host.name' } }, - { fieldColumn: { id: uuidv4(), field: 'event.dataset' } }, - { messageColumn: { id: uuidv4() } }, - ]; - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - columns: customColumns, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.columns).to.have.length(4); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const hostNameColumn = entry.columns[1] as LogFieldColumn; - expect(hostNameColumn).to.have.property('field'); - expect(hostNameColumn.field).to.be('host.name'); - expect(hostNameColumn).to.have.property('value'); - - const eventDatasetColumn = entry.columns[2] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[3] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - - it('Does not build context if entry does not have all fields', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.context).to.eql({}); - }); - - it('Paginates correctly with `after`', async () => { - const { body: firstPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - size: 10, - }) - ); - const firstPage = decodeOrThrow(logEntriesResponseRT)(firstPageBody); - - const { body: secondPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor!, - size: 10, - }) - ); - const secondPage = decodeOrThrow(logEntriesResponseRT)(secondPageBody); - - const { body: bothPagesBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - size: 20, - }) - ); - const bothPages = decodeOrThrow(logEntriesResponseRT)(bothPagesBody); - - expect(bothPages.data.entries).to.eql([ - ...firstPage.data.entries, - ...secondPage.data.entries, - ]); - - expect(bothPages.data.topCursor).to.eql(firstPage.data.topCursor); - expect(bothPages.data.bottomCursor).to.eql(secondPage.data.bottomCursor); - }); - - it('Paginates correctly with `before`', async () => { - const { body: lastPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: 'last', - size: 10, - }) - ); - const lastPage = decodeOrThrow(logEntriesResponseRT)(lastPageBody); - - const { body: secondToLastPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor!, - size: 10, - }) - ); - const secondToLastPage = decodeOrThrow(logEntriesResponseRT)(secondToLastPageBody); - - const { body: bothPagesBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: 'last', - size: 20, - }) - ); - const bothPages = decodeOrThrow(logEntriesResponseRT)(bothPagesBody); - - expect(bothPages.data.entries).to.eql([ - ...secondToLastPage.data.entries, - ...lastPage.data.entries, - ]); - - expect(bothPages.data.topCursor).to.eql(secondToLastPage.data.topCursor); - expect(bothPages.data.bottomCursor).to.eql(lastPage.data.bottomCursor); - }); - - it('centers entries around a point', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const firstEntry = entries[0]; - const lastEntry = entries[entries.length - 1]; - - expect(entries).to.have.length(200); - expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); - expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); - }); - - it('Handles empty responses', async () => { - const startTimestamp = Date.now() + 1000; - const endTimestamp = Date.now() + 5000; - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - expect(logEntriesResponse.data.entries).to.have.length(0); - expect(logEntriesResponse.data.topCursor).to.be(null); - expect(logEntriesResponse.data.bottomCursor).to.be(null); - }); - }); - - describe('with a configured source', () => { - before(async () => { - await esArchiver.load('empty_kibana'); - await sourceConfigurationService.createConfiguration('default', { - name: 'Test Source', - logColumns: [ - { - timestampColumn: { - id: uuidv4(), - }, - }, - { - fieldColumn: { - id: uuidv4(), - field: 'host.name', - }, - }, - { - fieldColumn: { - id: uuidv4(), - field: 'event.dataset', - }, - }, - { - messageColumn: { - id: uuidv4(), - }, - }, - ], - }); - }); - after(() => esArchiver.unload('empty_kibana')); - - it('returns the configured columns', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - - expect(entry.columns).to.have.length(4); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const hostNameColumn = entry.columns[1] as LogFieldColumn; - expect(hostNameColumn).to.have.property('field'); - expect(hostNameColumn.field).to.be('host.name'); - expect(hostNameColumn).to.have.property('value'); - - const eventDatasetColumn = entry.columns[2] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[3] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts b/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts deleted file mode 100644 index 864766b0e0710c..00000000000000 --- a/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts +++ /dev/null @@ -1,130 +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 expect from '@kbn/expect'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { identity } from 'fp-ts/lib/function'; -import { fold } from 'fp-ts/lib/Either'; - -import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; - -import { FtrProviderContext } from '../../ftr_provider_context'; -import { - LOG_ENTRIES_SUMMARY_PATH, - logEntriesSummaryRequestRT, - logEntriesSummaryResponseRT, - LOG_ENTRIES_PATH, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../../plugins/infra/common/http_api/log_entries'; - -const COMMON_HEADERS = { - 'kbn-xsrf': 'some-xsrf-token', -}; -const EARLIEST_KEY_WITH_DATA = { - time: new Date('2019-01-05T23:59:23.000Z').valueOf(), - tiebreaker: -1, -}; -const LATEST_KEY_WITH_DATA = { - time: new Date('2019-01-06T23:59:23.000Z').valueOf(), - tiebreaker: 2, -}; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; - -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - - describe('logs without epoch_millis format', () => { - before(() => esArchiver.load('infra/logs_without_epoch_millis')); - after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - - describe('/log_entries/summary', () => { - it('returns non-empty buckets', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); - }); - }); - - describe('/log_entries/entries', () => { - it('returns log entries', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - }) - ) - .expect(200); - - const logEntriesResponse = pipe( - logEntriesResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - expect(logEntriesResponse.data.entries).to.have.length(2); - }); - - it('returns log entries when centering around a point', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = pipe( - logEntriesResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - expect(logEntriesResponse.data.entries).to.have.length(2); - }); - }); - }); -} From 15277e187cf7b596a49d33f4cc1f2430d82ca098 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Thu, 11 Feb 2021 14:04:03 -0600 Subject: [PATCH 24/26] [Metrics UI] Fix alert preview accuracy with new Notify settings (#89939) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/common/alerting/metrics/types.ts | 1 + .../common/components/alert_preview.tsx | 20 ++++++- .../inventory/components/expression.test.tsx | 3 +- .../inventory/components/expression.tsx | 56 +++++++++++-------- .../components/expression.test.tsx | 3 +- .../metric_anomaly/components/expression.tsx | 51 ++++++++++------- .../components/expression.test.tsx | 3 +- .../components/expression.tsx | 40 +++++++------ .../components/expression_chart.tsx | 4 +- .../public/alerting/metric_threshold/types.ts | 8 ++- ...review_inventory_metric_threshold_alert.ts | 39 ++++++++----- .../preview_metric_anomaly_alert.ts | 28 +++++++--- .../preview_metric_threshold_alert.test.ts | 33 +++++++++++ .../preview_metric_threshold_alert.ts | 32 ++++++++--- .../alerting/metric_threshold/test_mocks.ts | 9 +++ .../infra/server/routes/alerting/preview.ts | 4 ++ .../alert_types/es_query/expression.test.tsx | 1 + .../alert_types/threshold/expression.test.tsx | 1 + .../sections/alert_form/alert_form.tsx | 1 + .../triggers_actions_ui/public/types.ts | 1 + 20 files changed, 237 insertions(+), 101 deletions(-) diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 7a4edb8f49189d..70515bde4b3fa7 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -75,6 +75,7 @@ const baseAlertRequestParamsRT = rt.intersection([ alertInterval: rt.string, alertThrottle: rt.string, alertOnNoData: rt.boolean, + alertNotifyWhen: rt.string, }), ]); diff --git a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx index 57c6f695453ef7..010d8bd84bf347 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { AlertNotifyWhenType } from '../../../../../alerts/common'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { FORMATTERS } from '../../../../common/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -36,6 +37,7 @@ import { getAlertPreview, PreviewableAlertTypes } from './get_alert_preview'; interface Props { alertInterval: string; alertThrottle: string; + alertNotifyWhen: AlertNotifyWhenType; alertType: PreviewableAlertTypes; alertParams: { criteria?: any[]; sourceId: string } & Record; validate: (params: any) => ValidationResult; @@ -48,6 +50,7 @@ export const AlertPreview: React.FC = (props) => { alertParams, alertInterval, alertThrottle, + alertNotifyWhen, alertType, validate, showNoDataResults, @@ -78,6 +81,7 @@ export const AlertPreview: React.FC = (props) => { lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData: showNoDataResults ?? false, } as AlertPreviewRequestParams, alertType, @@ -92,6 +96,7 @@ export const AlertPreview: React.FC = (props) => { alertParams, alertInterval, alertType, + alertNotifyWhen, groupByDisplayName, previewLookbackInterval, alertThrottle, @@ -119,10 +124,11 @@ export const AlertPreview: React.FC = (props) => { const showNumberOfNotifications = useMemo(() => { if (!previewResult) return false; + if (alertNotifyWhen === 'onActiveAlert') return false; const { notifications, fired, noData, error } = previewResult.resultTotals; const unthrottledNotifications = fired + (showNoDataResults ? noData + error : 0); return unthrottledNotifications > notifications; - }, [previewResult, showNoDataResults]); + }, [previewResult, showNoDataResults, alertNotifyWhen]); const hasWarningThreshold = useMemo( () => alertParams.criteria?.some((c) => Reflect.has(c, 'warningThreshold')) ?? false, @@ -213,9 +219,17 @@ export const AlertPreview: React.FC = (props) => { {i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 01720173a34380..891e98606264e0 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -48,8 +48,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={currentOptions} diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 4a05521e9fc87e..d43bbb6888a6e0 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -38,8 +38,10 @@ import { ForLastExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + IErrorObject, + AlertTypeParamsExpressionProps, +} from '../../../../../triggers_actions_ui/public'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; @@ -78,22 +80,21 @@ export interface AlertContextMeta { customMetrics?: SnapshotCustomMetricInput[]; } -interface Props { - errors: IErrorObject[]; - alertParams: { - criteria: InventoryMetricConditions[]; - nodeType: InventoryItemType; - filterQuery?: string; - filterQueryText?: string; - sourceId: string; - alertOnNoData?: boolean; - }; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type Criteria = InventoryMetricConditions[]; +type Props = Omit< + AlertTypeParamsExpressionProps< + { + criteria: Criteria; + nodeType: InventoryItemType; + filterQuery?: string; + filterQueryText?: string; + sourceId: string; + alertOnNoData?: boolean; + }, + AlertContextMeta + >, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; export const defaultExpression = { metric: 'cpu' as SnapshotMetricType, @@ -111,7 +112,15 @@ export const defaultExpression = { export const Expressions: React.FC = (props) => { const { http, notifications } = useKibanaContextForPlugin().services; - const { setAlertParams, alertParams, errors, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + errors, + alertInterval, + alertThrottle, + metadata, + alertNotifyWhen, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -186,7 +195,7 @@ export const Expressions: React.FC = (props) => { timeSize: ts, })); setTimeSize(ts || undefined); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as Criteria); }, [alertParams.criteria, setAlertParams] ); @@ -198,7 +207,7 @@ export const Expressions: React.FC = (props) => { timeUnit: tu, })); setTimeUnit(tu as Unit); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as Criteria); }, [alertParams.criteria, setAlertParams] ); @@ -301,7 +310,7 @@ export const Expressions: React.FC = (props) => { key={idx} // idx's don't usually make good key's but here the index has semantic meaning expressionId={idx} setAlertParams={updateParams} - errors={errors[idx] || emptyError} + errors={(errors[idx] as IErrorObject) || emptyError} expression={e || {}} fields={derivedIndexPattern.fields} /> @@ -385,6 +394,7 @@ export const Expressions: React.FC = (props) => { & { metric?: SnapshotMetricType; }; - errors: IErrorObject; + errors: AlertTypeParamsExpressionProps['errors']; canDelete: boolean; addExpression(): void; remove(id: number): void; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index ae2c6ed81badb4..3b3bece47e53f8 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -43,8 +43,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={currentOptions} diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 5938c7119616f0..5f034a600ecc68 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -22,8 +22,11 @@ import { WhenExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + AlertTypeParams, + AlertTypeParamsExpressionProps, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/types'; import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; import { findInventoryModel } from '../../../../common/inventory_models'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; @@ -41,29 +44,32 @@ export interface AlertContextMeta { nodeType?: InventoryItemType; } -interface Props { - errors: IErrorObject[]; - alertParams: MetricAnomalyParams & { - sourceId: string; - }; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type AlertParams = AlertTypeParams & + MetricAnomalyParams & { sourceId: string; hasInfraMLCapabilities: boolean }; + +type Props = Omit< + AlertTypeParamsExpressionProps, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; export const defaultExpression = { metric: 'memory_usage' as MetricAnomalyParams['metric'], - threshold: ANOMALY_THRESHOLD.MAJOR, - nodeType: 'hosts', + threshold: ANOMALY_THRESHOLD.MAJOR as MetricAnomalyParams['threshold'], + nodeType: 'hosts' as MetricAnomalyParams['nodeType'], influencerFilter: undefined, }; export const Expression: React.FC = (props) => { const { hasInfraMLCapabilities, isLoading: isLoadingMLCapabilities } = useInfraMLCapabilities(); const { http, notifications } = useKibanaContextForPlugin().services; - const { setAlertParams, alertParams, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + alertInterval, + alertThrottle, + alertNotifyWhen, + metadata, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -97,7 +103,7 @@ export const Expression: React.FC = (props) => { setAlertParams('influencerFilter', { ...alertParams.influencerFilter, fieldValue: value, - }); + } as MetricAnomalyParams['influencerFilter']); } else { setAlertParams('influencerFilter', undefined); } @@ -118,7 +124,7 @@ export const Expression: React.FC = (props) => { const updateMetric = useCallback( (metric: string) => { - setAlertParams('metric', metric); + setAlertParams('metric', metric as MetricAnomalyParams['metric']); }, [setAlertParams] ); @@ -249,6 +255,7 @@ export const Expression: React.FC = (props) => { { +const getMLMetricFromInventoryMetric: ( + metric: SnapshotMetricType +) => MetricAnomalyParams['metric'] | null = (metric) => { switch (metric) { case 'memory': return 'memory_usage'; @@ -308,7 +317,9 @@ const getMLMetricFromInventoryMetric = (metric: SnapshotMetricType) => { } }; -const getMLNodeTypeFromInventoryNodeType = (nodeType: InventoryItemType) => { +const getMLNodeTypeFromInventoryNodeType: ( + nodeType: InventoryItemType +) => MetricAnomalyParams['nodeType'] | null = (nodeType) => { switch (nodeType) { case 'host': return 'hosts'; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx index 7ceb37c4a2f6e3..a6d74d4f461a6c 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -44,8 +44,9 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} setAlertProperty={() => {}} metadata={{ diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index c3c3c20c4dd434..64190f55577071 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -30,8 +30,12 @@ import { ForLastExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + IErrorObject, + AlertTypeParams, + AlertTypeParamsExpressionProps, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/types'; import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; @@ -46,15 +50,10 @@ import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; const FILTER_TYPING_DEBOUNCE_MS = 500; -interface Props { - errors: IErrorObject[]; - alertParams: AlertParams; - alertInterval: string; - alertThrottle: string; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; - metadata: AlertContextMeta; -} +type Props = Omit< + AlertTypeParamsExpressionProps, + 'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' +>; const defaultExpression = { aggType: Aggregators.AVERAGE, @@ -66,7 +65,15 @@ const defaultExpression = { export { defaultExpression }; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertInterval, alertThrottle, metadata } = props; + const { + setAlertParams, + alertParams, + errors, + alertInterval, + alertThrottle, + metadata, + alertNotifyWhen, + } = props; const { http, notifications } = useKibanaContextForPlugin().services; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', @@ -76,7 +83,7 @@ export const Expressions: React.FC = (props) => { }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('m'); + const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, ]); @@ -174,7 +181,7 @@ export const Expressions: React.FC = (props) => { timeUnit: tu, })) || []; setTimeUnit(tu as Unit); - setAlertParams('criteria', criteria); + setAlertParams('criteria', criteria as AlertParams['criteria']); }, [alertParams.criteria, setAlertParams] ); @@ -191,7 +198,7 @@ export const Expressions: React.FC = (props) => { timeSize, timeUnit, aggType: metric.aggregation, - })) + })) as AlertParams['criteria'] ); } else { setAlertParams('criteria', [defaultExpression]); @@ -280,7 +287,7 @@ export const Expressions: React.FC = (props) => { key={idx} // idx's don't usually make good key's but here the index has semantic meaning expressionId={idx} setAlertParams={updateParams} - errors={errors[idx] || emptyError} + errors={(errors[idx] as IErrorObject) || emptyError} expression={e || {}} > = (props) => { = ({ ) : ( @@ -336,7 +336,7 @@ export const ExpressionChart: React.FC = ({ )} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index c49918d3dd3794..fca4160199030b 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -17,8 +17,10 @@ export interface AlertContextMeta { series?: MetricsExplorerSeries; } -export type MetricExpression = Omit & { - metric?: string; +export type MetricExpression = Omit & { + metric?: MetricExpressionParams['metric']; + timeSize?: MetricExpressionParams['timeSize']; + timeUnit?: MetricExpressionParams['timeUnit']; }; export enum AGGREGATION_TYPES { @@ -54,7 +56,7 @@ export interface ExpressionChartData { export interface AlertParams { criteria: MetricExpression[]; - groupBy?: string[]; + groupBy?: string | string[]; filterQuery?: string; sourceId: string; filterQueryText?: string; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index 5fff76260e5c69..6f3299a2cc126f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -34,6 +34,7 @@ interface PreviewInventoryMetricThresholdAlertParams { alertInterval: string; alertThrottle: string; alertOnNoData: boolean; + alertNotifyWhen: string; } export const previewInventoryMetricThresholdAlert: ( @@ -46,7 +47,8 @@ export const previewInventoryMetricThresholdAlert: ( alertInterval, alertThrottle, alertOnNoData, -}) => { + alertNotifyWhen, +}: PreviewInventoryMetricThresholdAlertParams) => { const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); @@ -62,9 +64,7 @@ export const previewInventoryMetricThresholdAlert: ( const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); - const executionsPerThrottle = Math.floor( - (throttleIntervalInSeconds / alertIntervalInSeconds) * alertResultsPerExecution - ); + try { const results = await Promise.all( criteria.map((c) => @@ -82,9 +82,17 @@ export const previewInventoryMetricThresholdAlert: ( let numberOfErrors = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker++; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } + previousActionGroup = actionGroup; }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); @@ -105,23 +113,26 @@ export const previewInventoryMetricThresholdAlert: ( if (someConditionsErrorInMappedBucket) { numberOfErrors++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (someConditionsNoDataInMappedBucket) { numberOfNoDataResults++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; - notifyWithThrottle(); + notifyWithThrottle('fired'); } else if (allConditionsWarnInMappedBucket) { numberOfTimesWarned++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker++; + notifyWithThrottle('warning'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } - if (throttleTracker === executionsPerThrottle) { + if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; } } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts index 98992701e3bb4e..b5033bb9a6043d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts @@ -27,6 +27,7 @@ interface PreviewMetricAnomalyAlertParams { alertInterval: string; alertThrottle: string; alertOnNoData: boolean; + alertNotifyWhen: string; } export const previewMetricAnomalyAlert = async ({ @@ -38,12 +39,12 @@ export const previewMetricAnomalyAlert = async ({ lookback, alertInterval, alertThrottle, + alertNotifyWhen, }: PreviewMetricAnomalyAlertParams) => { const { metric, threshold, influencerFilter, nodeType } = params as MetricAnomalyParams; const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); - const executionsPerThrottle = Math.floor(throttleIntervalInSeconds / alertIntervalInSeconds); const lookbackInterval = `1${lookback}`; const lookbackIntervalInSeconds = getIntervalInSeconds(lookbackInterval); @@ -78,9 +79,17 @@ export const previewMetricAnomalyAlert = async ({ let numberOfTimesFired = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker++; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } + previousActionGroup = actionGroup; }; // Mock each alert evaluation for (let i = 0; i < numberOfExecutions; i++) { @@ -102,11 +111,14 @@ export const previewMetricAnomalyAlert = async ({ if (anomaliesDetectedInBuckets) { numberOfTimesFired++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker++; + notifyWithThrottle('fired'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } - if (throttleTracker === executionsPerThrottle) { + if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; } } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts index 1adca25504b1fd..c9616377acf8f6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts @@ -19,6 +19,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(30); @@ -34,6 +35,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '3m', alertThrottle: '3m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(10); @@ -48,6 +50,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '30s', alertThrottle: '30s', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(60); @@ -62,6 +65,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '3m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(30); @@ -69,6 +73,30 @@ describe('Previewing the metric threshold alert type', () => { expect(error).toBe(0); expect(notifications).toBe(15); }); + test('returns the expected results using a notify setting of Only on Status Change', async () => { + const [ungroupedResult] = await previewMetricThresholdAlert({ + ...baseParams, + params: { + ...baseParams.params, + criteria: [ + { + ...baseCriterion, + metric: 'test.metric.3', + } as MetricExpressionParams, + ], + }, + lookback: 'h', + alertInterval: '1m', + alertThrottle: '1m', + alertOnNoData: true, + alertNotifyWhen: 'onActionGroupChange', + }); + const { fired, noData, error, notifications } = ungroupedResult; + expect(fired).toBe(20); + expect(noData).toBe(0); + expect(error).toBe(0); + expect(notifications).toBe(20); + }); }); describe('querying with a groupBy parameter', () => { test('returns the expected results', async () => { @@ -82,6 +110,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired: firedA, @@ -122,6 +151,7 @@ describe('Previewing the metric threshold alert type', () => { alertInterval: '1m', alertThrottle: '1m', alertOnNoData: true, + alertNotifyWhen: 'onThrottleInterval', }); const { fired, noData, error, notifications } = ungroupedResult; expect(fired).toBe(25); @@ -144,6 +174,9 @@ services.callCluster.mockImplementation(async (_: string, { body, index }: any) if (metric === 'test.metric.2') { return mocks.alternateMetricPreviewResponse; } + if (metric === 'test.metric.3') { + return mocks.repeatingMetricPreviewResponse; + } return mocks.basicMetricPreviewResponse; }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index b9fa6659d5fcd0..fe2a88d89bf4a4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -31,6 +31,7 @@ interface PreviewMetricThresholdAlertParams { lookback: Unit; alertInterval: string; alertThrottle: string; + alertNotifyWhen: string; alertOnNoData: boolean; end?: number; overrideLookbackIntervalInSeconds?: number; @@ -48,6 +49,7 @@ export const previewMetricThresholdAlert: ( lookback, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, end = Date.now(), overrideLookbackIntervalInSeconds, @@ -104,9 +106,17 @@ export const previewMetricThresholdAlert: ( let numberOfErrors = 0; let numberOfNotifications = 0; let throttleTracker = 0; - const notifyWithThrottle = () => { - if (throttleTracker === 0) numberOfNotifications++; - throttleTracker += alertIntervalInSeconds; + let previousActionGroup: string | null = null; + const notifyWithThrottle = (actionGroup: string) => { + if (alertNotifyWhen === 'onActionGroupChange') { + if (previousActionGroup !== actionGroup) numberOfNotifications++; + previousActionGroup = actionGroup; + } else if (alertNotifyWhen === 'onThrottleInterval') { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + } else { + numberOfNotifications++; + } }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); @@ -126,21 +136,24 @@ export const previewMetricThresholdAlert: ( if (someConditionsErrorInMappedBucket) { numberOfErrors++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (someConditionsNoDataInMappedBucket) { numberOfNoDataResults++; if (alertOnNoData) { - notifyWithThrottle(); + notifyWithThrottle('fired'); // TODO: Update this when No Data alerts move to an action group } } else if (allConditionsFiredInMappedBucket) { numberOfTimesFired++; - notifyWithThrottle(); + notifyWithThrottle('fired'); } else if (allConditionsWarnInMappedBucket) { numberOfTimesWarned++; - notifyWithThrottle(); - } else if (throttleTracker > 0) { - throttleTracker += alertIntervalInSeconds; + notifyWithThrottle('warning'); + } else { + previousActionGroup = 'recovered'; + if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } } if (throttleTracker >= throttleIntervalInSeconds) { throttleTracker = 0; @@ -168,6 +181,7 @@ export const previewMetricThresholdAlert: ( alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, }; const { maxBuckets } = e; // If this is still the first iteration, try to get the number of groups in order to diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index 20736db5425de9..2d4f2b16c78a4a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -45,6 +45,7 @@ const previewBucketsWithNulls = [ ...Array.from(Array(10), (_, i) => ({ aggregatedValue: { value: null } })), ...previewBucketsA.slice(10), ]; +const previewBucketsRepeat = Array.from(Array(60), (_, i) => bucketsA[Math.max(0, (i % 3) - 1)]); export const basicMetricResponse = { aggregations: { @@ -175,6 +176,14 @@ export const alternateMetricPreviewResponse = { }, }; +export const repeatingMetricPreviewResponse = { + aggregations: { + aggregatedIntervals: { + buckets: previewBucketsRepeat, + }, + }, +}; + export const basicCompositePreviewResponse = { aggregations: { groupings: { diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 3da560135eaf48..d1807583acd392 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -43,6 +43,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, } = request.body; const callCluster = (endpoint: string, opts: Record) => { @@ -69,6 +70,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) config: source.configuration, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, }); @@ -90,6 +92,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) source, alertInterval, alertThrottle, + alertNotifyWhen, alertOnNoData, }); @@ -119,6 +122,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) alertInterval, alertThrottle, alertOnNoData, + alertNotifyWhen, }); return response.ok({ diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx index 27ddb28eed779d..f475d97e2f39d3 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -147,6 +147,7 @@ describe('EsQueryAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx index 01c2bc18f35e8f..28f0f3db196143 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx @@ -89,6 +89,7 @@ describe('IndexThresholdAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 66bab7e41ab54c..06eaa8285991cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -591,6 +591,7 @@ export const AlertForm = ({ alertParams={alert.params} alertInterval={`${alertInterval ?? 1}${alertIntervalUnit}`} alertThrottle={`${alertThrottle ?? 1}${alertThrottleUnit}`} + alertNotifyWhen={alert.notifyWhen ?? 'onActionGroupChange'} errors={errors} setAlertParams={setAlertParams} setAlertProperty={setAlertProperty} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6fb52cf1151d56..3e41d27596c34e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -198,6 +198,7 @@ export interface AlertTypeParamsExpressionProps< alertParams: Params; alertInterval: string; alertThrottle: string; + alertNotifyWhen: AlertNotifyWhenType; setAlertParams: (property: Key, value: Params[Key] | undefined) => void; setAlertProperty: ( key: Prop, From befb7c62a580f9c52cae765285fbf045ff2b1a94 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 11 Feb 2021 12:16:40 -0800 Subject: [PATCH 25/26] [Time to Visualize] Adds functional tests for editing by value visualize embeddables (#90241) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/dashboard/edit_visualizations.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/functional/apps/dashboard/edit_visualizations.js b/test/functional/apps/dashboard/edit_visualizations.js index 9d7f4a5a378205..a918c017bd88f8 100644 --- a/test/functional/apps/dashboard/edit_visualizations.js +++ b/test/functional/apps/dashboard/edit_visualizations.js @@ -108,5 +108,72 @@ export default function ({ getService, getPageObjects }) { await PageObjects.common.clickConfirmOnModal(); expect(await testSubjects.exists('visualizationLandingPage')).to.be(true); }); + + describe('by value', () => { + it('save and return button returns to dashboard after editing visualization with changes saved', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(); + + const originalPanelCount = PageObjects.dashboard.getPanelCount(); + + await editMarkdownVis(); + await PageObjects.visualize.saveVisualizationAndReturn(); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(modifiedMarkdownText); + + const newPanelCount = PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount); + }); + + it('cancel button returns to dashboard after editing visualization without saving', async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(); + + await editMarkdownVis(); + await PageObjects.visualize.cancelAndReturn(true); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(originalMarkdownText); + }); + + it('save to library button returns to dashboard after editing visualization with changes saved', async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + + await createMarkdownVis(); + + const originalPanelCount = PageObjects.dashboard.getPanelCount(); + + await editMarkdownVis(); + await PageObjects.visualize.saveVisualization('test save to library', { + redirectToOrigin: true, + }); + + const markdownText = await testSubjects.find('markdownBody'); + expect(await markdownText.getVisibleText()).to.eql(modifiedMarkdownText); + + const newPanelCount = PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(originalPanelCount); + }); + + it('should lose its connection to the dashboard when creating new visualization', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.clickNewVisualization(); + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visualize.notLinkedToOriginatingApp(); + + // return to origin should not be present in save modal + await testSubjects.click('visualizeSaveButton'); + const redirectToOriginCheckboxExists = await testSubjects.exists( + 'returnToOriginModeSwitch' + ); + expect(redirectToOriginCheckboxExists).to.be(false); + }); + }); }); } From 30e86ac0659d5769d6f3665b3d5d63e3297a70d4 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Thu, 11 Feb 2021 12:17:09 -0800 Subject: [PATCH 26/26] =?UTF-8?q?[Dashboard]=20Adds=C2=A0Save=20as=20butto?= =?UTF-8?q?n=20to=20top=20menu=20(#90320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/top_nav/dashboard_top_nav.tsx | 43 ++++++-- .../application/top_nav/get_top_nav_config.ts | 104 +++++++++--------- .../panel_toolbar.stories.storyshot | 3 +- .../top_nav/panel_toolbar/panel_toolbar.tsx | 3 +- .../public/application/top_nav/top_nav_ids.ts | 3 +- .../apps/dashboard/dashboard_save.ts | 20 ++++ .../apps/dashboard/empty_dashboard.ts | 4 +- .../functional/page_objects/dashboard_page.ts | 12 ++ .../services/dashboard/visualizations.ts | 2 +- .../new_visualize_flow/dashboard_embedding.ts | 4 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../dashboard_mode/dashboard_empty_screen.js | 4 +- 13 files changed, 129 insertions(+), 81 deletions(-) diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 0caaac6764bbe9..786afc81c400cd 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -321,6 +321,33 @@ export function DashboardTopNav({ dashboardStateManager, ]); + const runQuickSave = useCallback(async () => { + const currentTitle = dashboardStateManager.getTitle(); + const currentDescription = dashboardStateManager.getDescription(); + const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + + save({}).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + dashboardStateManager.setTitle(currentTitle); + dashboardStateManager.setDescription(currentDescription); + dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } + } + return response; + }); + }, [save, savedObjectsTagging, dashboardStateManager]); + const runClone = useCallback(() => { const currentTitle = dashboardStateManager.getTitle(); const onClone = async ( @@ -356,9 +383,8 @@ export function DashboardTopNav({ [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, + [TopNavIds.QUICK_SAVE]: runQuickSave, [TopNavIds.CLONE]: runClone, - [TopNavIds.ADD_EXISTING]: addFromLibrary, - [TopNavIds.VISUALIZE]: createNew, [TopNavIds.OPTIONS]: (anchorElement) => { showOptionsPopover({ anchorElement, @@ -394,10 +420,9 @@ export function DashboardTopNav({ onDiscardChanges, onChangeViewMode, savedDashboard, - addFromLibrary, - createNew, runClone, runSave, + runQuickSave, share, ]); @@ -419,11 +444,11 @@ export function DashboardTopNav({ const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showSearchBar = showQueryBar || showFilterBar; - const topNav = getTopNavConfig( - viewMode, - dashboardTopNavActions, - dashboardCapabilities.hideWriteControls - ); + const topNav = getTopNavConfig(viewMode, dashboardTopNavActions, { + hideWriteControls: dashboardCapabilities.hideWriteControls, + isNewDashboard: !savedDashboard.id, + isDirty: dashboardStateManager.isDirty, + }); return { appName: 'dashboard', diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 37414cb948d5ae..abc128369017c5 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -20,11 +20,11 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + options: { hideWriteControls: boolean; isNewDashboard: boolean; isDirty: boolean } ) { switch (dashboardMode) { case ViewMode.VIEW: - return hideWriteControls + return options.hideWriteControls ? [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), getShareConfig(actions[TopNavIds.SHARE]), @@ -36,20 +36,39 @@ export function getTopNavConfig( getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), ]; case ViewMode.EDIT: - return [ - getOptionsConfig(actions[TopNavIds.OPTIONS]), - getShareConfig(actions[TopNavIds.SHARE]), - getAddConfig(actions[TopNavIds.ADD_EXISTING]), - getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), - getSaveConfig(actions[TopNavIds.SAVE]), - getCreateNewConfig(actions[TopNavIds.VISUALIZE]), - ]; + return options.isNewDashboard + ? [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard), + ] + : [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE]), + getQuickSave(actions[TopNavIds.QUICK_SAVE]), + ]; default: return []; } } +function getSaveButtonLabel() { + return i18n.translate('dashboard.topNave.saveButtonAriaLabel', { + defaultMessage: 'save', + }); +} + +function getSaveAsButtonLabel() { + return i18n.translate('dashboard.topNave.saveAsButtonAriaLabel', { + defaultMessage: 'save as', + }); +} + function getFullScreenConfig(action: NavAction) { return { id: 'full-screen', @@ -89,17 +108,32 @@ function getEditConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getSaveConfig(action: NavAction) { +function getQuickSave(action: NavAction) { return { - id: 'save', - label: i18n.translate('dashboard.topNave.saveButtonAriaLabel', { - defaultMessage: 'save', - }), + id: 'quick-save', + emphasize: true, + label: getSaveButtonLabel(), description: i18n.translate('dashboard.topNave.saveConfigDescription', { - defaultMessage: 'Save your dashboard', + defaultMessage: 'Quick save your dashboard without any prompts', + }), + testId: 'dashboardQuickSaveMenuItem', + run: action, + }; +} + +/** + * @returns {kbnTopNavConfig} + */ +function getSaveConfig(action: NavAction, isNewDashboard = false) { + return { + id: 'save', + label: isNewDashboard ? getSaveButtonLabel() : getSaveAsButtonLabel(), + description: i18n.translate('dashboard.topNave.saveAsConfigDescription', { + defaultMessage: 'Save as a new dashboard', }), testId: 'dashboardSaveMenuItem', run: action, + emphasize: isNewDashboard, }; } @@ -157,42 +191,6 @@ function getCloneConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getAddConfig(action: NavAction) { - return { - id: 'add', - label: i18n.translate('dashboard.topNave.addButtonAriaLabel', { - defaultMessage: 'Library', - }), - description: i18n.translate('dashboard.topNave.addConfigDescription', { - defaultMessage: 'Add an existing visualization to the dashboard', - }), - testId: 'dashboardAddPanelButton', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getCreateNewConfig(action: NavAction) { - return { - emphasize: true, - iconType: 'plusInCircleFilled', - id: 'addNew', - label: i18n.translate('dashboard.topNave.addNewButtonAriaLabel', { - defaultMessage: 'Create panel', - }), - description: i18n.translate('dashboard.topNave.addNewConfigDescription', { - defaultMessage: 'Create a new panel on this dashboard', - }), - testId: 'dashboardAddNewPanelButton', - run: action, - }; -} - -// /** -// * @returns {kbnTopNavConfig} -// */ function getShareConfig(action: NavAction | undefined) { return { id: 'share', diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot index f822a7e70d523c..afbbecb3935e0e 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot @@ -10,7 +10,7 @@ exports[`Storyshots components/PanelToolbar default 1`] = ` >