From 295ac7ef121ee16e875e6f83c8abada85ca39483 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 26 Jun 2020 15:36:51 -0600 Subject: [PATCH 01/38] [Security] `Investigate in Resolver` Timeline Integration (#70111) ## [Security] `Investigate in Resolver` Timeline Integration This PR adds a new `Investigate in Resolver` action to the Timeline, and all timeline-based views, including: - Timeline - Alert list (i.e. Signals) - Hosts > Events - Hosts > External alerts - Network > External alerts ![investigate-in-resolver-action](https://user-images.githubusercontent.com/4459398/85886173-c40d1c80-b7a2-11ea-8011-0221fef95d51.png) ### Resolver Overlay When the `Investigate in Resolver` action is clicked, Resolver is displayed in an overlay over the events. The screenshot below has placeholder text where Resolver will be rendered: ![resolver-overlay](https://user-images.githubusercontent.com/4459398/85886309-10f0f300-b7a3-11ea-95cb-0117207e4890.png) The Resolver overlay is closed by clicking the `< Back to events` button shown in the screenshot above. The state of the timeline is restored when the overlay is closed. The scroll position (within the events), any expanded events, etc, will appear exactly as they were before the Resolver overlay was displayed. ### Case Integration Users may link directly to a Timeline Resolver view from cases via the `Attach to new case` and `Attach to existing case...` actions show in the screenshot below: ![case-integration](https://user-images.githubusercontent.com/4459398/85886773-e3587980-b7a3-11ea-87b6-b098ea14bc5f.png) ![investigate-in-resolver](https://user-images.githubusercontent.com/4459398/85885618-daff3f00-b7a1-11ea-9356-2e8a1291f213.gif) When users click the link in a case, Timeline will automatically open to the Resolver view in the link. ### URL State Users can directly share Resolver views (in saved Timelines) with other users by copying the Kibana URL to the clipboard when Resolver is open. When another user pastes the URL in their browser, Timeline will automatically open and display the Resolver view in the URL. ### Enabling the `Investigate in Resolver` action In this PR, the `Investigate in Resolver` action is only enabled for events where all of the following are true: - `agent.type` is `endpoint` - `process.entity_id` exists ### Context passed to Resolver The only context passed to `Resolver` is the `_id` of the event (when the user clicks `Investigate in Resolver`) ### What's next? - @oatkiller will replace the placeholder text shown in the screenshots above with the actual call to Resolver in a separate PR - I will follow-up this PR with additional tests - The action text `Investigate in Resolver` may be changed in a future PR - Hide the `Add to case` action in timeline-based views (it's currently visible, but disabled) --- .../alerts_table/default_config.tsx | 24 +- .../components/alerts_table/index.test.tsx | 53 +- .../alerts/components/alerts_table/index.tsx | 7 +- .../components/alerts_viewer/alerts_table.tsx | 10 +- .../events_viewer/events_viewer.tsx | 14 +- .../navigation/breadcrumbs/index.test.ts | 1 + .../components/navigation/index.test.tsx | 4 +- .../navigation/tab_navigation/index.test.tsx | 2 + .../common/components/url_state/helpers.ts | 3 +- .../url_state/initialize_redux_by_url.tsx | 1 + .../public/graphql/introspection.json | 35 + .../security_solution/public/graphql/types.ts | 18 + .../fields_browser/categories_pane.tsx | 6 +- .../fields_browser/field_browser.tsx | 6 +- .../components/fields_browser/index.tsx | 1 + .../components/flyout/header/index.tsx | 6 +- .../components/graph_overlay/index.tsx | 150 ++ .../components/graph_overlay/translations.ts | 14 + .../components/open_timeline/helpers.ts | 3 + .../__snapshots__/timeline.test.tsx.snap | 1778 +++++++++-------- .../timeline/body/actions/index.tsx | 40 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../timeline/body/column_headers/index.tsx | 35 +- .../components/timeline/body/constants.ts | 6 +- .../body/events/event_column_view.tsx | 17 +- .../components/timeline/body/helpers.ts | 36 +- .../components/timeline/body/index.test.tsx | 6 + .../components/timeline/body/index.tsx | 19 +- .../timeline/body/stateful_body.tsx | 13 +- .../components/timeline/body/translations.ts | 7 + .../components/timeline/header/index.tsx | 41 +- .../timelines/components/timeline/helpers.tsx | 2 + .../components/timeline/index.test.tsx | 1 + .../timelines/components/timeline/index.tsx | 5 + .../insert_timeline_popover/index.test.tsx | 6 +- .../insert_timeline_popover/index.tsx | 12 +- .../use_insert_timeline.tsx | 21 +- .../timeline/properties/helpers.test.tsx | 1 + .../timeline/properties/helpers.tsx | 49 +- .../timeline/properties/index.test.tsx | 12 +- .../components/timeline/properties/index.tsx | 14 +- .../timeline/properties/properties_right.tsx | 3 + .../timeline/properties/translations.ts | 16 +- .../timeline/selectable_timeline/index.tsx | 9 +- .../timelines/components/timeline/styles.tsx | 23 +- .../components/timeline/timeline.test.tsx | 6 +- .../components/timeline/timeline.tsx | 11 + .../timelines/containers/index.gql_query.ts | 4 + .../timelines/store/timeline/actions.ts | 4 + .../timelines/store/timeline/helpers.ts | 20 + .../public/timelines/store/timeline/model.ts | 4 + .../timelines/store/timeline/reducer.test.ts | 2 +- .../timelines/store/timeline/reducer.ts | 6 + .../public/timelines/store/timeline/types.ts | 1 + .../server/graphql/ecs/schema.gql.ts | 6 + .../security_solution/server/graphql/types.ts | 35 + .../server/lib/ecs_fields/index.ts | 6 + 57 files changed, 1615 insertions(+), 1024 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx index 2029c5169c2cdc..6d82897aaf0102 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/default_config.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ApolloClient from 'apollo-client'; +import { Dispatch } from 'redux'; import { EuiText } from '@elastic/eui'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -17,10 +18,12 @@ import { TimelineRowActionOnClick, } from '../../../timelines/components/timeline/body/actions'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, } from '../../../timelines/components/timeline/body/constants'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../timelines/components/timeline/helpers'; import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -174,23 +177,27 @@ export const getAlertActions = ({ apolloClient, canUserCRUD, createTimeline, + dispatch, hasIndexWrite, onAlertStatusUpdateFailure, onAlertStatusUpdateSuccess, setEventsDeleted, setEventsLoading, status, + timelineId, updateTimelineIsLoading, }: { apolloClient?: ApolloClient<{}>; canUserCRUD: boolean; createTimeline: CreateTimeline; + dispatch: Dispatch; hasIndexWrite: boolean; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; onAlertStatusUpdateSuccess: (count: number, status: Status) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; status: Status; + timelineId: string; updateTimelineIsLoading: UpdateTimelineLoading; }): TimelineRowAction[] => { const openAlertActionComponent: TimelineRowAction = { @@ -199,7 +206,7 @@ export const getAlertActions = ({ dataTestSubj: 'open-alert-status', displayType: 'contextMenu', id: FILTER_OPEN, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -210,7 +217,7 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_OPEN, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; const closeAlertActionComponent: TimelineRowAction = { @@ -219,7 +226,7 @@ export const getAlertActions = ({ dataTestSubj: 'close-alert-status', displayType: 'contextMenu', id: FILTER_CLOSED, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -230,7 +237,7 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_CLOSED, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; const inProgressAlertActionComponent: TimelineRowAction = { @@ -239,7 +246,7 @@ export const getAlertActions = ({ dataTestSubj: 'in-progress-alert-status', displayType: 'contextMenu', id: FILTER_IN_PROGRESS, - isActionDisabled: !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ alertIds: [eventId], @@ -250,10 +257,13 @@ export const getAlertActions = ({ status, selectedStatus: FILTER_IN_PROGRESS, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }; return [ + { + ...getInvestigateInResolverAction({ dispatch, timelineId }), + }, { ariaLabel: 'Send alert to timeline', content: i18n.ACTION_INVESTIGATE_IN_TIMELINE, @@ -268,7 +278,7 @@ export const getAlertActions = ({ ecsData, updateTimelineIsLoading, }), - width: 26, + width: DEFAULT_ICON_BUTTON_WIDTH, }, // Context menu items ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx index f843bf68818465..9ff368aff2bf68 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.test.tsx @@ -7,37 +7,40 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { TestProviders } from '../../../common/mock/test_providers'; import { TimelineId } from '../../../../common/types/timeline'; import { AlertsTableComponent } from './index'; describe('AlertsTableComponent', () => { it('renders correctly', () => { const wrapper = shallow( - + + + ); expect(wrapper.find('[title="Alerts"]')).toBeTruthy(); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx index ba6102312fef67..ec088c111e3bbc 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx @@ -7,7 +7,7 @@ import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -84,6 +84,7 @@ export const AlertsTableComponent: React.FC = ({ updateTimeline, updateTimelineIsLoading, }) => { + const dispatch = useDispatch(); const [selectAll, setSelectAll] = useState(false); const apolloClient = useApolloClient(); @@ -292,11 +293,13 @@ export const AlertsTableComponent: React.FC = ({ getAlertActions({ apolloClient, canUserCRUD, + dispatch, hasIndexWrite, createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, status: filterGroup, + timelineId, updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, @@ -305,10 +308,12 @@ export const AlertsTableComponent: React.FC = ({ apolloClient, canUserCRUD, createTimelineCallback, + dispatch, hasIndexWrite, filterGroup, setEventsLoadingCallback, setEventsDeletedCallback, + timelineId, updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 251e0278b11bab..6d5471404ab4d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -5,13 +5,16 @@ */ import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; import { StatefulEventsViewer } from '../events_viewer'; import { alertsDefaultModel } from './default_headers'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import * as i18n from './translations'; + export interface OwnProps { end: number; id: string; @@ -64,8 +67,9 @@ const AlertsTableComponent: React.FC = ({ startDate, pageFilters = [], }) => { + const dispatch = useDispatch(); const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); - const { initializeTimeline } = useManageTimeline(); + const { initializeTimeline, setTimelineRowActions } = useManageTimeline(); useEffect(() => { initializeTimeline({ @@ -75,6 +79,10 @@ const AlertsTableComponent: React.FC = ({ title: i18n.ALERTS_TABLE_TITLE, unit: i18n.UNIT, }); + setTimelineRowActions({ + id: timelineId, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId })], + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( 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 6b4baac0ff26c4..9e38b14c4334a5 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 @@ -7,6 +7,7 @@ import { EuiPanel } from '@elastic/eui'; import { getOr, isEmpty, union } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -34,6 +35,7 @@ import { } from '../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; const DEFAULT_EVENTS_VIEWER_HEIGHT = 500; @@ -91,6 +93,7 @@ const EventsViewerComponent: React.FC = ({ toggleColumn, utilityBar, }) => { + const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const { filterManager } = useKibana().services.data.query; @@ -100,7 +103,16 @@ const EventsViewerComponent: React.FC = ({ getManageTimelineById, setIsTimelineLoading, setTimelineFilterManager, + setTimelineRowActions, } = useManageTimeline(); + + useEffect(() => { + setTimelineRowActions({ + id, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId: id })], + }); + }, [setTimelineRowActions, id, dispatch]); + useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -179,9 +191,7 @@ const EventsViewerComponent: React.FC = ({ {headerFilterGroup} - {utilityBar?.(refetch, totalCountMinusDeleted)} - { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }, }; @@ -160,6 +161,7 @@ describe('SIEM Navigation', () => { timeline: { id: '', isOpen: false, + graphEventId: '', }, timerange: { global: { @@ -266,7 +268,7 @@ describe('SIEM Navigation', () => { search: '', state: undefined, tabName: 'authentications', - timeline: { id: '', isOpen: false }, + timeline: { id: '', isOpen: false, graphEventId: '' }, timerange: { global: { linkTo: ['timeline'], diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx index 977c7808b6c86e..f345346d620cb1 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx @@ -71,6 +71,7 @@ describe('Tab Navigation', () => { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }; test('it mounts with correct tab highlighted', () => { @@ -128,6 +129,7 @@ describe('Tab Navigation', () => { [CONSTANTS.timeline]: { id: '', isOpen: false, + graphEventId: '', }, }; test('it mounts with correct tab highlighted', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index c270a99d3c51e5..7f4267bc5e2b34 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -126,8 +126,9 @@ export const makeMapStateToProps = () => { ? { id: flyoutTimeline.savedObjectId != null ? flyoutTimeline.savedObjectId : '', isOpen: flyoutTimeline.show, + graphEventId: flyoutTimeline.graphEventId ?? '', } - : { id: '', isOpen: false }; + : { id: '', isOpen: false, graphEventId: '' }; let searchAttr: { [CONSTANTS.appQuery]?: Query; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index efd6221bbfbd02..ab03e2199474c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -81,6 +81,7 @@ export const dispatchSetInitialStateFromUrl = ( queryTimelineById({ apolloClient, duplicate: false, + graphEventId: timeline.graphEventId, timelineId: timeline.id, openTimeline: timeline.isOpen, updateIsLoading: updateTimelineIsLoading, diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 3c8c7c21d72a02..48547212bb6c01 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -3570,6 +3570,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "agent", + "description": "", + "args": [], + "type": { "kind": "OBJECT", "name": "AgentEcsField", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "auditd", "description": "", @@ -3760,6 +3768,25 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AgentEcsField", + "description": "", + "fields": [ + { + "name": "type", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AuditdEcsFields", @@ -5728,6 +5755,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "entity_id", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "executable", "description": "", diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index dc4a8ae78bf46d..b5088fe51b446b 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -763,6 +763,8 @@ export interface Ecs { _index?: Maybe; + agent?: Maybe; + auditd?: Maybe; destination?: Maybe; @@ -810,6 +812,10 @@ export interface Ecs { system?: Maybe; } +export interface AgentEcsField { + type?: Maybe; +} + export interface AuditdEcsFields { result?: Maybe; @@ -1265,6 +1271,8 @@ export interface ProcessEcsFields { args?: Maybe; + entity_id?: Maybe; + executable?: Maybe; title?: Maybe; @@ -4605,6 +4613,8 @@ export namespace GetTimelineQuery { event: Maybe; + agent: Maybe; + auditd: Maybe; file: Maybe; @@ -4730,6 +4740,12 @@ export namespace GetTimelineQuery { type: Maybe; }; + export type Agent = { + __typename?: 'AgentEcsField'; + + type: Maybe; + }; + export type Auditd = { __typename?: 'AuditdEcsFields'; @@ -5155,6 +5171,8 @@ export namespace GetTimelineQuery { args: Maybe; + entity_id: Maybe; + executable: Maybe; title: Maybe; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx index 480070fda9594e..7addfaaf7c5fce 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.tsx @@ -32,6 +32,10 @@ const Title = styled(EuiTitle)` padding-left: 5px; `; +const H5 = styled.h5` + text-align: left; +`; + Title.displayName = 'Title'; type Props = Pick & { @@ -64,7 +68,7 @@ export const CategoriesPane = React.memo( }) => ( <> - <h5 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</h5> + <H5 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</H5> ` border: ${({ theme }) => theme.eui.euiBorderWidthThin} solid ${({ theme }) => theme.eui.euiColorMediumShade}; border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; - left: 0; + left: 8px; padding: ${({ theme }) => theme.eui.paddingSizes.s} ${({ theme }) => theme.eui.paddingSizes.s} - ${({ theme }) => theme.eui.paddingSizes.m}; + ${({ theme }) => theme.eui.paddingSizes.s}; position: absolute; - top: calc(100% + ${({ theme }) => theme.eui.euiSize}); + top: calc(100% + 4px); width: ${({ width }) => width}px; z-index: 9990; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx index a3e93ff3c90eb9..a3937107936b68 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx @@ -26,6 +26,7 @@ export const INPUT_TIMEOUT = 250; const FieldsBrowserButtonContainer = styled.div` position: relative; + width: 24px; `; FieldsBrowserButtonContainer.displayName = 'FieldsBrowserButtonContainer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 8ad32d6e2cad01..9fe48cd2f0190b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -33,6 +33,7 @@ const StatefulFlyoutHeader = React.memo( associateNote, createTimeline, description, + graphEventId, isDataInTimeline, isDatepickerLocked, isFavorite, @@ -58,6 +59,7 @@ const StatefulFlyoutHeader = React.memo( createTimeline={createTimeline} description={description} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} isDataInTimeline={isDataInTimeline} isDatepickerLocked={isDatepickerLocked} isFavorite={isFavorite} @@ -92,6 +94,7 @@ const makeMapStateToProps = () => { const { dataProviders, description = '', + graphEventId, isFavorite = false, kqlQuery, title = '', @@ -103,13 +106,14 @@ const makeMapStateToProps = () => { return { description, - notesById: getNotesByIds(state), + graphEventId, history, isDataInTimeline: !isEmpty(dataProviders) || !isEmpty(get('filterQuery.kuery.expression', kqlQuery)), isFavorite, isDatepickerLocked: globalInput.linkTo.includes('timeline'), noteIds, + notesById: getNotesByIds(state), status, title, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx new file mode 100644 index 00000000000000..fe38dd79176a5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiTitle, +} from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React, { useCallback, useState } from 'react'; +import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; +import styled from 'styled-components'; + +import { SecurityPageName } from '../../../app/types'; +import { AllCasesModal } from '../../../cases/components/all_cases_modal'; +import { getCaseDetailsUrl } from '../../../common/components/link_to'; +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; +import { State } from '../../../common/store'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { TimelineModel } from '../../store/timeline/model'; +import { NewCase, ExistingCase } from '../timeline/properties/helpers'; +import { UNTITLED_TIMELINE } from '../timeline/properties/translations'; +import { + setInsertTimeline, + updateTimelineGraphEventId, +} from '../../../timelines/store/timeline/actions'; + +import * as i18n from './translations'; + +const OverlayContainer = styled.div<{ bodyHeight?: number }>` + height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; + width: 100%; +`; + +interface OwnProps { + bodyHeight?: number; + graphEventId?: string; + timelineId: string; +} + +const GraphOverlayComponent = ({ + bodyHeight, + graphEventId, + status, + timelineId, + title, +}: OwnProps & PropsFromRedux) => { + const dispatch = useDispatch(); + const { navigateToApp } = useKibana().services.application; + const onCloseOverlay = useCallback(() => { + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); + }, [dispatch, timelineId]); + const [showCaseModal, setShowCaseModal] = useState(false); + const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); + const onCloseCaseModal = useCallback(() => setShowCaseModal(false), [setShowCaseModal]); + const currentTimeline = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); + const onRowClick = useCallback( + (id: string) => { + onCloseCaseModal(); + + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: currentTimeline.savedObjectId, + timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, + }) + ); + + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id }), + }); + }, + [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] + ); + + return ( + + + + + + {i18n.BACK_TO_EVENTS} + + + + + + + + + + + + + + + + + <>{`Resolver graph for event _id ${graphEventId}`} + + + + ); +}; + +const makeMapStateToProps = () => { + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const mapStateToProps = (state: State, { timelineId }: OwnProps) => { + const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; + const { status, title = '' } = timeline; + + return { + status, + title, + }; + }; + return mapStateToProps; +}; + +const connector = connect(makeMapStateToProps); + +type PropsFromRedux = ConnectedProps; + +export const GraphOverlay = connector(GraphOverlayComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts new file mode 100644 index 00000000000000..c7cd9253de0383 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const BACK_TO_EVENTS = i18n.translate( + 'xpack.securitySolution.timeline.graphOverlay.backToEventsButton', + { + defaultMessage: '< Back to events', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index c8a47798f169ce..520215cde4862c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -190,6 +190,7 @@ export const formatTimelineResultToModel = ( export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate?: boolean; + graphEventId?: string; timelineId: string; onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; @@ -206,6 +207,7 @@ export interface QueryTimelineById { export const queryTimelineById = ({ apolloClient, duplicate = false, + graphEventId = '', timelineId, onOpenTimeline, openTimeline = true, @@ -238,6 +240,7 @@ export const queryTimelineById = ({ notes, timeline: { ...timeline, + graphEventId, show: openTimeline, }, to: getOr(to, 'dateRange.end', timeline), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 4e6cce618880b1..92782252719303 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -1,882 +1,942 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Timeline rendering renders correctly against snapshot 1`] = ` - - - - - + + + + + + - - - - - - - + Object { + "aggregatable": true, + "category": "host", + "columnHeaderType": "not-filtered", + "description": "Name of the host. +It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", + "example": "", + "id": "host.name", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "source", + "columnHeaderType": "not-filtered", + "description": "IP address of the source. +Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "id": "source.ip", + "type": "ip", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "destination", + "columnHeaderType": "not-filtered", + "description": "IP address of the destination. +Can be one or multiple IPv4 or IPv6 addresses.", + "example": "", + "id": "destination.ip", + "type": "ip", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "destination", + "columnHeaderType": "not-filtered", + "description": "Bytes sent from the source to the destination", + "example": "123", + "format": "bytes", + "id": "destination.bytes", + "type": "number", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "user", + "columnHeaderType": "not-filtered", + "description": "Short name or login of the user.", + "example": "albert", + "id": "user.name", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": true, + "category": "base", + "columnHeaderType": "not-filtered", + "description": "Each document has an _id that uniquely identifies it", + "example": "Y-6TfmcB0WOhS6qyMv3s", + "id": "_id", + "type": "keyword", + "width": 180, + }, + Object { + "aggregatable": false, + "category": "base", + "columnHeaderType": "not-filtered", + "description": "For log events the message field contains the log message. +In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", + "example": "Hello World", + "id": "message", + "type": "text", + "width": 180, + }, + ] + } + dataProviders={ + Array [ + Object { + "and": Array [ + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 2", + "kqlQuery": "", + "name": "Provider 2", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 2", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 3", + "kqlQuery": "", + "name": "Provider 3", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 3", + }, + }, + ], + "enabled": true, + "excluded": false, + "id": "id-Provider 1", + "kqlQuery": "", + "name": "Provider 1", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 1", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 2", + "kqlQuery": "", + "name": "Provider 2", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 2", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 3", + "kqlQuery": "", + "name": "Provider 3", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 3", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 4", + "kqlQuery": "", + "name": "Provider 4", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 4", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 5", + "kqlQuery": "", + "name": "Provider 5", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 5", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 6", + "kqlQuery": "", + "name": "Provider 6", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 6", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 7", + "kqlQuery": "", + "name": "Provider 7", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 7", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 8", + "kqlQuery": "", + "name": "Provider 8", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 8", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 9", + "kqlQuery": "", + "name": "Provider 9", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 9", + }, + }, + Object { + "and": Array [], + "enabled": true, + "excluded": false, + "id": "id-Provider 10", + "kqlQuery": "", + "name": "Provider 10", + "queryMatch": Object { + "field": "name", + "operator": ":", + "value": "Provider 10", + }, + }, + ] + } + end={1521862432253} + eventType="raw" + filters={Array []} + id="foo" + indexPattern={ + Object { + "fields": Array [ + Object { + "aggregatable": true, + "name": "@timestamp", + "searchable": true, + "type": "date", + }, + Object { + "aggregatable": true, + "name": "@version", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.ephemeral_id", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.hostname", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.id", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test1", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test2", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test3", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test4", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test5", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test6", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test7", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "agent.test8", + "searchable": true, + "type": "string", + }, + Object { + "aggregatable": true, + "name": "host.name", + "searchable": true, + "type": "string", + }, + ], + "title": "filebeat-*,auditbeat-*,packetbeat-*", + } + } + indexToAdd={Array []} + isLive={false} + itemsPerPage={5} + itemsPerPageOptions={ + Array [ + 5, + 10, + 20, + ] + } + kqlMode="search" + kqlQueryExpression="" + loadingIndexName={false} + onChangeItemsPerPage={[MockFunction]} + onClose={[MockFunction]} + onDataProviderEdited={[MockFunction]} + onDataProviderRemoved={[MockFunction]} + onToggleDataProviderEnabled={[MockFunction]} + onToggleDataProviderExcluded={[MockFunction]} + show={true} + showCallOutUnauthorizedMsg={false} + sort={ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + } + } + start={1521830963132} + toggleColumn={[MockFunction]} + usersViewing={ + Array [ + "elastic", + ] + } + /> + + + + + + `; 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 ef744ab562e712..b478070b315783 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 @@ -15,6 +15,7 @@ import { eventHasNotes, getPinTooltip } from '../helpers'; import * as i18n from '../translations'; import { OnRowSelected } from '../../events'; import { Ecs } from '../../../../../graphql/types'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; export interface TimelineRowActionOnClick { eventId: string; @@ -27,7 +28,7 @@ export interface TimelineRowAction { displayType: 'icon' | 'contextMenu'; iconType?: string; id: string; - isActionDisabled?: boolean; + isActionDisabled?: (ecsData?: Ecs) => boolean; onClick: ({ eventId, ecsData }: TimelineRowActionOnClick) => void; content: string | JSX.Element; width?: number; @@ -83,24 +84,9 @@ export const Actions = React.memo( actionsColumnWidth={actionsColumnWidth} data-test-subj="event-actions-container" > - - - {loading && } - - {!loading && ( - - )} - - {showCheckboxes && ( - + {loadingEventIds.includes(eventId) ? ( ) : ( @@ -120,12 +106,28 @@ export const Actions = React.memo( )} + + + {loading && } + + {!loading && ( + + )} + + + <>{additionalActions} {!isEventViewer && ( <> - + ( - + + {showSelectAllCheckbox && ( + + + + + + )} + - + + {showEventsSelect && ( - + )} - {showSelectAllCheckbox && ( - - - - - - )} ( ...acc, icon: [ ...acc.icon, - + ( aria-label={action.ariaLabel} data-test-subj={`${action.dataTestSubj}-button`} iconType={action.iconType} - isDisabled={action.isActionDisabled ?? false} + isDisabled={ + action.isActionDisabled != null ? action.isActionDisabled(ecsData) : false + } onClick={() => action.onClick({ eventId: id, ecsData })} /> @@ -155,7 +158,9 @@ export const EventColumnView = React.memo( onClickCb(() => action.onClick({ eventId: id, ecsData }))} @@ -170,7 +175,11 @@ export const EventColumnView = React.memo( return grouped.contextMenu.length > 0 ? [ ...grouped.icon, - + => { } return 'raw'; }; + +export const showGraphView = (graphEventId?: string) => + graphEventId != null && graphEventId.length > 0; + +export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { + return ( + get(['agent', 'type', 0], ecsData) === 'endpoint' && + get(['process', 'entity_id'], ecsData)?.length > 0 + ); +}; + +export const getInvestigateInResolverAction = ({ + dispatch, + timelineId, +}: { + dispatch: Dispatch; + timelineId: string; +}): TimelineRowAction => ({ + ariaLabel: i18n.ACTION_INVESTIGATE_IN_RESOLVER, + content: i18n.ACTION_INVESTIGATE_IN_RESOLVER, + dataTestSubj: 'investigate-in-resolver', + displayType: 'icon', + iconType: 'node', + id: 'investigateInResolver', + isActionDisabled: (ecsData?: Ecs) => !isInvestigateInResolverActionEnabled(ecsData), + onClick: ({ eventId }: TimelineRowActionOnClick) => + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: eventId })), + width: DEFAULT_ICON_BUTTON_WIDTH, +}); 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 775c26e82d27bc..9b96e0c49c73d7 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 @@ -70,6 +70,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -108,6 +109,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -146,6 +148,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -186,6 +189,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -271,6 +275,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} @@ -316,6 +321,7 @@ describe('Body', () => { pinnedEventIds={{}} rowRenderers={rowRenderers} selectedEventIds={{}} + show={true} sort={mockSort} showCheckboxes={false} toggleColumn={jest.fn()} 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 da8835d5903e19..46895c86de084a 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 @@ -26,10 +26,13 @@ import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; import { ColumnHeaders } from './column_headers'; import { getActionsColumnWidth } from './column_headers/helpers'; import { Events } from './events'; +import { showGraphView } from './helpers'; import { ColumnRenderer } from './renderers/column_renderer'; import { RowRenderer } from './renderers/row_renderer'; import { Sort } from './sort'; import { useManageTimeline } from '../../manage_timeline'; +import { GraphOverlay } from '../../graph_overlay'; +import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -38,6 +41,7 @@ export interface BodyProps { columnRenderers: ColumnRenderer[]; data: TimelineItem[]; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; height?: number; id: string; isEventViewer?: boolean; @@ -56,6 +60,7 @@ export interface BodyProps { pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; selectedEventIds: Readonly>; + show: boolean; showCheckboxes: boolean; sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; @@ -72,6 +77,7 @@ export const Body = React.memo( data, eventIdToNoteIds, getNotesByIds, + graphEventId, height, id, isEventViewer = false, @@ -89,6 +95,7 @@ export const Body = React.memo( pinnedEventIds, rowRenderers, selectedEventIds, + show, showCheckboxes, sort, toggleColumn, @@ -108,7 +115,7 @@ export const Body = React.memo( if (v.displayType === 'icon') { return acc + (v.width ?? 0); } - const addWidth = hasContextMenu ? 0 : 26; + const addWidth = hasContextMenu ? 0 : DEFAULT_ICON_BUTTON_WIDTH; hasContextMenu = true; return acc + addWidth; }, 0) ?? 0 @@ -127,7 +134,15 @@ export const Body = React.memo( return ( <> - + {showGraphView(graphEventId) && ( + + )} + ( selectedEventIds, setSelected, clearSelected, + show, showCheckboxes, showRowRenderers, + graphEventId, sort, toggleColumn, unPinEvent, @@ -180,6 +183,7 @@ const StatefulBodyComponent = React.memo( data={data} eventIdToNoteIds={eventIdToNoteIds} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} height={height} id={id} isEventViewer={isEventViewer} @@ -197,6 +201,7 @@ const StatefulBodyComponent = React.memo( pinnedEventIds={pinnedEventIds} rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} selectedEventIds={selectedEventIds} + show={id === ACTIVE_TIMELINE_REDUX_ID ? show : true} showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} @@ -209,6 +214,7 @@ const StatefulBodyComponent = React.memo( deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && deepEqual(prevProps.data, nextProps.data) && prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.graphEventId === nextProps.graphEventId && deepEqual(prevProps.notesById, nextProps.notesById) && prevProps.height === nextProps.height && prevProps.id === nextProps.id && @@ -216,6 +222,7 @@ const StatefulBodyComponent = React.memo( prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && prevProps.loadingEventIds === nextProps.loadingEventIds && prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.show === nextProps.show && prevProps.selectedEventIds === nextProps.selectedEventIds && prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.showRowRenderers === nextProps.showRowRenderers && @@ -238,10 +245,12 @@ const makeMapStateToProps = () => { columns, eventIdToNoteIds, eventType, + graphEventId, isSelectAllChecked, loadingEventIds, pinnedEventIds, selectedEventIds, + show, showCheckboxes, showRowRenderers, } = timeline; @@ -250,12 +259,14 @@ const makeMapStateToProps = () => { columnHeaders: memoizedColumnHeaders(columns, browserFields), eventIdToNoteIds, eventType, + graphEventId, isSelectAllChecked, loadingEventIds, notesById: getNotesByIds(state), id, pinnedEventIds, selectedEventIds, + show, showCheckboxes, showRowRenderers, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index 98f544f30ae8b5..63b92d6b316cc8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -51,3 +51,10 @@ export const COLLAPSE = i18n.translate( defaultMessage: 'Collapse', } ); + +export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip', + { + defaultMessage: 'Investigate in Resolver', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index fb47eb331fdbbf..e8f1e73719234d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { FilterManager, IIndexPattern } from 'src/plugins/data/public'; import deepEqual from 'fast-deep-equal'; +import { showGraphView } from '../body/helpers'; import { DataProviders } from '../data_providers'; import { DataProvider } from '../data_providers/data_provider'; import { @@ -26,6 +27,7 @@ interface Props { browserFields: BrowserFields; dataProviders: DataProvider[]; filterManager: FilterManager; + graphEventId?: string; id: string; indexPattern: IIndexPattern; onDataProviderEdited: OnDataProviderEdited; @@ -42,6 +44,7 @@ const TimelineHeaderComponent: React.FC = ({ indexPattern, dataProviders, filterManager, + graphEventId, onDataProviderEdited, onDataProviderRemoved, onToggleDataProviderEnabled, @@ -59,24 +62,27 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - {show && ( - - )} - + {show && !showGraphView(graphEventId) && ( + <> + + + + + )} ); @@ -88,6 +94,7 @@ export const TimelineHeader = React.memo( deepEqual(prevProps.indexPattern, nextProps.indexPattern) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && prevProps.filterManager === nextProps.filterManager && + prevProps.graphEventId === nextProps.graphEventId && prevProps.onDataProviderEdited === nextProps.onDataProviderEdited && prevProps.onDataProviderRemoved === nextProps.onDataProviderRemoved && prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index b5481e9d4eee24..a3fc692c3a8a85 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -153,3 +153,5 @@ export const combineQueries = ({ * the `Timeline` and the `Events Viewer` widget */ export const STATEFUL_EVENT_CSS_CLASS_NAME = 'event-column-view'; + +export const DEFAULT_ICON_BUTTON_WIDTH = 24; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 5ccc8911d1974b..83ac1a421958be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -72,6 +72,7 @@ describe('StatefulTimeline', () => { eventType: 'raw', end: endDate, filters: [], + graphEventId: undefined, id: 'foo', isLive: false, isTimelineExists: false, 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 df76eb350ace7f..a66c01d0b5d0b9 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 @@ -41,6 +41,7 @@ const StatefulTimelineComponent = React.memo( eventType, end, filters, + graphEventId, id, isLive, isTimelineExists, @@ -168,6 +169,7 @@ const StatefulTimelineComponent = React.memo( end={end} eventType={eventType} filters={filters} + graphEventId={graphEventId} id={id} indexPattern={indexPattern} indexToAdd={indexToAdd} @@ -196,6 +198,7 @@ const StatefulTimelineComponent = React.memo( return ( prevProps.eventType === nextProps.eventType && prevProps.end === nextProps.end && + prevProps.graphEventId === nextProps.graphEventId && prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && prevProps.itemsPerPage === nextProps.itemsPerPage && @@ -229,6 +232,7 @@ const makeMapStateToProps = () => { dataProviders, eventType, filters, + graphEventId, itemsPerPage, itemsPerPageOptions, kqlMode, @@ -245,6 +249,7 @@ const makeMapStateToProps = () => { eventType, end: input.timerange.to, filters: timelineFilter, + graphEventId, id, isLive: input.policy.kind === 'interval', isTimelineExists: getTimeline(state, id) != null, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx index 2ffbae1f7eb5c0..5e6f35e8397e48 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.test.tsx @@ -50,7 +50,11 @@ describe('Insert timeline popover ', () => { payload: { id: 'timeline-id', show: false }, type: 'x-pack/security_solution/local/timeline/SHOW_TIMELINE', }); - expect(onTimelineChange).toBeCalledWith('Timeline title', '34578-3497-5893-47589-34759'); + expect(onTimelineChange).toBeCalledWith( + 'Timeline title', + '34578-3497-5893-47589-34759', + undefined + ); expect(mockDispatch.mock.calls[1][0]).toEqual({ payload: null, type: 'x-pack/security_solution/local/timeline/SET_INSERT_TIMELINE', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx index de199d9a1cc2eb..83417cdb51b699 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx @@ -19,7 +19,11 @@ import { setInsertTimeline } from '../../../store/timeline/actions'; interface InsertTimelinePopoverProps { isDisabled: boolean; hideUntitled?: boolean; - onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; + onTimelineChange: ( + timelineTitle: string, + timelineId: string | null, + graphEventId?: string + ) => void; } type Props = InsertTimelinePopoverProps; @@ -38,7 +42,11 @@ export const InsertTimelinePopoverComponent: React.FC = ({ useEffect(() => { if (insertTimeline != null) { dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false })); - onTimelineChange(insertTimeline.timelineTitle, insertTimeline.timelineSavedObjectId); + onTimelineChange( + insertTimeline.timelineTitle, + insertTimeline.timelineSavedObjectId, + insertTimeline.graphEventId + ); dispatch(setInsertTimeline(null)); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx index c3def9c4cbb292..c3bcd1c0ebe516 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { useCallback, useState } from 'react'; import { useBasePath } from '../../../../common/lib/kibana'; import { CursorPosition } from '../../../../common/components/markdown_editor'; @@ -16,8 +17,10 @@ export const useInsertTimeline = (form: FormHook, fieldNa end: 0, }); const handleOnTimelineChange = useCallback( - (title: string, id: string | null) => { - const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}',isOpen:!t)`; + (title: string, id: string | null, graphEventId?: string) => { + const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}'${ + !isEmpty(graphEventId) ? `,graphEventId:'${graphEventId}'` : '' + },isOpen:!t)`; const currentValue = form.getFormData()[fieldName]; const newValue: string = [ currentValue.slice(0, cursorPosition.start), @@ -28,16 +31,12 @@ export const useInsertTimeline = (form: FormHook, fieldNa ].join(''); form.setFieldValue(fieldName, newValue); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [form] - ); - const handleCursorChange = useCallback( - (cp: CursorPosition) => { - setCursorPosition(cp); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [cursorPosition] + [basePath, cursorPosition, fieldName, form] ); + const handleCursorChange = useCallback((cp: CursorPosition) => { + setCursorPosition(cp); + }, []); + return { cursorPosition, handleCursorChange, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index d8c9d2ed02cc6e..aec09a95b4b195 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -17,6 +17,7 @@ jest.mock('../../../../common/lib/kibana', () => { useKibana: jest.fn().mockReturnValue({ services: { application: { + navigateToApp: jest.fn(), capabilities: { siem: { crud: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index f2e7d26c9e8516..528af23191ee9b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -20,7 +20,6 @@ import { import React, { useCallback } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; -import { useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { APP_ID } from '../../../../../common/constants'; @@ -28,11 +27,10 @@ import { TimelineTypeLiteral, TimelineStatus, TimelineType, + TimelineId, } from '../../../../../common/types/timeline'; -import { navTabs } from '../../../../app/home/home_navigations'; import { SecurityPageName } from '../../../../app/types'; import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { useGetUrlSearch } from '../../../../common/components/navigation/use_get_url_search'; import { getCreateCaseUrl } from '../../../../common/components/link_to'; import { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; @@ -44,7 +42,7 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { NOTES_PANEL_WIDTH } from './notes_size'; import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles'; import * as i18n from './translations'; -import { setInsertTimeline } from '../../../store/timeline/actions'; +import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; export const historyToolTip = 'The chronological history of actions related to this timeline'; @@ -139,6 +137,8 @@ export const Name = React.memo(({ timelineId, title, updateTitle }) = Name.displayName = 'Name'; interface NewCaseProps { + compact?: boolean; + graphEventId?: string; onClosePopover: () => void; timelineId: string; timelineStatus: TimelineStatus; @@ -146,44 +146,50 @@ interface NewCaseProps { } export const NewCase = React.memo( - ({ onClosePopover, timelineId, timelineStatus, timelineTitle }) => { - const history = useHistory(); - const urlSearch = useGetUrlSearch(navTabs.case); + ({ compact, graphEventId, onClosePopover, timelineId, timelineStatus, timelineTitle }) => { const dispatch = useDispatch(); const { savedObjectId } = useSelector((state: State) => timelineSelectors.selectTimeline(state, timelineId) ); const { navigateToApp } = useKibana().services.application; + const buttonText = compact ? i18n.ATTACH_TO_NEW_CASE : i18n.ATTACH_TIMELINE_TO_NEW_CASE; const handleClick = useCallback(() => { onClosePopover(); dispatch( setInsertTimeline({ + graphEventId, timelineId, timelineSavedObjectId: savedObjectId, timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, }) ); + dispatch(showTimeline({ id: TimelineId.active, show: false })); + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCreateCaseUrl(urlSearch), - }); - history.push({ - pathname: `/${SecurityPageName.case}/create`, + path: getCreateCaseUrl(), }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, navigateToApp, onClosePopover, history, timelineId, timelineTitle, urlSearch]); + }, [ + dispatch, + graphEventId, + navigateToApp, + onClosePopover, + savedObjectId, + timelineId, + timelineTitle, + ]); return ( - {i18n.ATTACH_TIMELINE_TO_NEW_CASE} + {buttonText} ); } @@ -191,28 +197,33 @@ export const NewCase = React.memo( NewCase.displayName = 'NewCase'; interface ExistingCaseProps { + compact?: boolean; onClosePopover: () => void; onOpenCaseModal: () => void; timelineStatus: TimelineStatus; } export const ExistingCase = React.memo( - ({ onClosePopover, onOpenCaseModal, timelineStatus }) => { + ({ compact, onClosePopover, onOpenCaseModal, timelineStatus }) => { const handleClick = useCallback(() => { onClosePopover(); onOpenCaseModal(); }, [onOpenCaseModal, onClosePopover]); + const buttonText = compact + ? i18n.ATTACH_TO_EXISTING_CASE + : i18n.ATTACH_TIMELINE_TO_EXISTING_CASE; return ( <> - {i18n.ATTACH_TIMELINE_TO_EXISTING_CASE} + {buttonText} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 3078700a29d76b..1b76db409484f4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -17,7 +17,6 @@ import { import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Properties, showDescriptionThreshold, showNotesThreshold } from '.'; -import { SecurityPageName } from '../../../../app/types'; import { setInsertTimeline } from '../../../store/timeline/actions'; export { nextTick } from '../../../../../../../test_utils'; @@ -25,12 +24,13 @@ import { act } from 'react-dom/test-utils'; jest.mock('../../../../common/components/link_to'); +const mockNavigateToApp = jest.fn(); jest.mock('../../../../common/lib/kibana', () => { const original = jest.requireActual('../../../../common/lib/kibana'); return { ...original, - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { capabilities: { @@ -38,7 +38,7 @@ jest.mock('../../../../common/lib/kibana', () => { crud: true, }, }, - navigateToApp: jest.fn(), + navigateToApp: mockNavigateToApp, }, }, }), @@ -63,7 +63,6 @@ jest.mock('react-redux', () => { useSelector: jest.fn().mockReturnValue({ savedObjectId: '1', urlState: {} }), }; }); -const mockHistoryPush = jest.fn(); jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -71,7 +70,7 @@ jest.mock('react-router-dom', () => { return { ...original, useHistory: () => ({ - push: mockHistoryPush, + push: jest.fn(), }), }; }); @@ -342,8 +341,7 @@ describe('Properties', () => { ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); wrapper.find('[data-test-subj="attach-timeline-case"]').first().simulate('click'); - - expect(mockHistoryPush).toBeCalledWith({ pathname: `/${SecurityPageName.case}/create` }); + expect(mockNavigateToApp).toBeCalledWith('securitySolution:case', { path: '/create' }); expect(mockDispatch).toBeCalledWith( setInsertTimeline({ timelineId: defaultProps.timelineId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 602a7c8191c7a2..8029d166a688a5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -46,6 +46,7 @@ interface Props { createTimeline: CreateTimeline; description: string; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; isDataInTimeline: boolean; isDatepickerLocked: boolean; isFavorite: boolean; @@ -79,6 +80,7 @@ export const Properties = React.memo( createTimeline, description, getNotesByIds, + graphEventId, isDataInTimeline, isDatepickerLocked, isFavorite, @@ -120,18 +122,21 @@ export const Properties = React.memo( const onRowClick = useCallback( (id: string) => { onCloseCaseModal(); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id }), - }); + dispatch( setInsertTimeline({ + graphEventId, timelineId, timelineSavedObjectId: currentTimeline.savedObjectId, timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, }) ); + + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id }), + }); }, - [navigateToApp, onCloseCaseModal, currentTimeline, dispatch, timelineId, title] + [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] ); const datePickerWidth = useMemo( @@ -174,6 +179,7 @@ export const Properties = React.memo( associateNote={associateNote} description={description} getNotesByIds={getNotesByIds} + graphEventId={graphEventId} isDataInTimeline={isDataInTimeline} noteIds={noteIds} onButtonClick={onButtonClick} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index 7d176d57b5d818..e20a3db80d8812 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -68,6 +68,7 @@ interface PropertiesRightComponentProps { associateNote: AssociateNote; description: string; getNotesByIds: (noteIds: string[]) => Note[]; + graphEventId?: string; isDataInTimeline: boolean; noteIds: string[]; onButtonClick: () => void; @@ -94,6 +95,7 @@ const PropertiesRightComponent: React.FC = ({ associateNote, description, getNotesByIds, + graphEventId, isDataInTimeline, noteIds, onButtonClick, @@ -166,6 +168,7 @@ const PropertiesRightComponent: React.FC = ({ EuiSelectableOption[]; onClosePopover: () => void; - onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; + onTimelineChange: ( + timelineTitle: string, + timelineId: string | null, + graphEventId?: string + ) => void; timelineType: TimelineTypeLiteral; } @@ -202,7 +206,8 @@ const SelectableTimelineComponent: React.FC = ({ isEmpty(selectedTimeline[0].title) ? i18nTimeline.UNTITLED_TIMELINE : selectedTimeline[0].title, - selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id, + selectedTimeline[0].graphEventId ?? '' ); } onClosePopover(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index aad80cbdfe3372..55bcbbecda2690 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -24,11 +24,12 @@ export const TimelineBodyGlobalStyle = createGlobalStyle` export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ className: `siemTimeline__body ${className}`, -}))<{ bodyHeight?: number }>` +}))<{ bodyHeight?: number; visible: boolean }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; overflow: auto; scrollbar-width: thin; flex: 1; + visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')}; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; @@ -89,10 +90,9 @@ export const EventsTrHeader = styled.div.attrs(({ className }) => ({ export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupActions ${className}`, -}))<{ actionsColumnWidth: number; justifyContent: string }>` +}))<{ actionsColumnWidth: number }>` display: flex; flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; - justify-content: ${({ justifyContent }) => justifyContent}; min-width: 0; `; @@ -139,14 +139,17 @@ export const EventsTh = styled.div.attrs(({ className = '' }) => ({ export const EventsThContent = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thContent ${className}`, -}))<{ textAlign?: string }>` +}))<{ textAlign?: string; width?: number }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; min-width: 0; padding: ${({ theme }) => theme.eui.paddingSizes.xs}; text-align: ${({ textAlign }) => textAlign}; - width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ + width: ${({ width }) => + width != null + ? `${width}px` + : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; /* EVENTS BODY */ @@ -202,7 +205,6 @@ export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupActions ${className}`, }))<{ actionsColumnWidth: number }>` display: flex; - justify-content: space-between; flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; min-width: 0; `; @@ -234,14 +236,17 @@ export const EventsTd = styled.div.attrs(({ className = '', width }) `; export const EventsTdContent = styled.div.attrs(({ className }) => ({ - className: `siemEventsTable__tdContent ${className}`, -}))<{ textAlign?: string }>` + className: `siemEventsTable__tdContent ${className != null ? className : ''}`, +}))<{ textAlign?: string; width?: number }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; min-width: 0; padding: ${({ theme }) => theme.eui.paddingSizes.xs}; text-align: ${({ textAlign }) => textAlign}; - width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ + width: ${({ width }) => + width != null + ? `${width}px` + : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 96703941f616e3..79ec58711e06c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -103,7 +103,11 @@ describe('Timeline', () => { describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow( + + + + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 884d693ca6ade0..85e3d5d9478b63 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -7,6 +7,7 @@ import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; import { getOr, isEmpty } from 'lodash/fp'; import React, { useState, useMemo, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { FlyoutHeaderWithCloseButton } from '../flyout/header_with_close_button'; @@ -16,6 +17,7 @@ import { Direction } from '../../../graphql/types'; import { useKibana } from '../../../common/lib/kibana'; import { ColumnHeaderOptions, KqlMode, EventType } from '../../../timelines/store/timeline/model'; import { defaultHeaders } from './body/column_headers/default_headers'; +import { getInvestigateInResolverAction } from './body/helpers'; import { Sort } from './body/sort'; import { StatefulBody } from './body/stateful_body'; import { DataProvider } from './data_providers/data_provider'; @@ -88,6 +90,7 @@ export interface Props { end: number; eventType?: EventType; filters: Filter[]; + graphEventId?: string; id: string; indexPattern: IIndexPattern; indexToAdd: string[]; @@ -119,6 +122,7 @@ export const TimelineComponent: React.FC = ({ end, eventType, filters, + graphEventId, id, indexPattern, indexToAdd, @@ -141,6 +145,7 @@ export const TimelineComponent: React.FC = ({ toggleColumn, usersViewing, }) => { + const dispatch = useDispatch(); const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); const combinedQueries = combineQueries({ @@ -168,9 +173,14 @@ export const TimelineComponent: React.FC = ({ initializeTimeline, setIsTimelineLoading, setTimelineFilterManager, + setTimelineRowActions, } = useManageTimeline(); useEffect(() => { initializeTimeline({ id, indexToAdd }); + setTimelineRowActions({ + id, + timelineRowActions: [getInvestigateInResolverAction({ dispatch, timelineId: id })], + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -197,6 +207,7 @@ export const TimelineComponent: React.FC = ({ indexPattern={indexPattern} dataProviders={dataProviders} filterManager={filterManager} + graphEventId={graphEventId} onDataProviderEdited={onDataProviderEdited} onDataProviderRemoved={onDataProviderRemoved} onToggleDataProviderEnabled={onToggleDataProviderEnabled} diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts index 53d0b98570bcbf..e2a268e750b4a5 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts @@ -89,6 +89,9 @@ export const timelineQuery = gql` timezone type } + agent { + type + } auditd { result session @@ -285,6 +288,7 @@ export const timelineQuery = gql` name ppid args + entity_id executable title working_directory 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 c5df017604b0c6..55e6849fdb6c4a 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 @@ -87,6 +87,10 @@ export const removeProvider = actionCreator<{ export const showTimeline = actionCreator<{ id: string; show: boolean }>('SHOW_TIMELINE'); +export const updateTimelineGraphEventId = actionCreator<{ id: string; graphEventId: string }>( + 'UPDATE_TIMELINE_GRAPH_EVENT_ID' +); + export const unPinEvent = actionCreator<{ id: string; eventId: string }>('UN_PIN_EVENT'); export const updateTimeline = actionCreator<{ 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 15f956fa79d3cc..c0615d36f7a2e0 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 @@ -228,6 +228,26 @@ export const updateTimelineShowTimeline = ({ }; }; +export const updateGraphEventId = ({ + id, + graphEventId, + timelineById, +}: { + id: string; + graphEventId: string; + timelineById: TimelineById; +}): TimelineById => { + const timeline = timelineById[id]; + + return { + ...timelineById, + [id]: { + ...timeline, + graphEventId, + }, + }; +}; + interface ApplyDeltaToCurrentWidthParams { id: string; delta: number; 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 caad70226365af..e8ea3c8d16e3a8 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 @@ -55,6 +55,8 @@ export interface TimelineModel { /** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */ eventIdToNoteIds: Record; filters?: Filter[]; + /** When non-empty, display a graph view for this event */ + graphEventId?: string; /** The chronological history of actions related to this timeline */ historyIds: string[]; /** The chronological history of actions related to this timeline */ @@ -129,6 +131,7 @@ export type SubsetTimelineModel = Readonly< | 'description' | 'eventType' | 'eventIdToNoteIds' + | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' | 'isFavorite' @@ -165,4 +168,5 @@ export type SubsetTimelineModel = Readonly< export interface TimelineUrl { id: string; isOpen: boolean; + graphEventId?: string; } 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 3bdb16be79939a..6e7a36079a0c34 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 @@ -1788,6 +1788,7 @@ describe('Timeline', () => { isLoading: false, id: 'foo', savedObjectId: null, + showRowRenderers: true, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, loadingEventIds: [], @@ -1802,7 +1803,6 @@ describe('Timeline', () => { }, selectedEventIds: {}, show: true, - showRowRenderers: true, showCheckboxes: false, sort: { columnId: '@timestamp', 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 5e314f15974513..30b7f73c839d19 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 @@ -53,6 +53,7 @@ import { updateRange, updateSort, updateTimeline, + updateTimelineGraphEventId, updateTitle, upsertColumn, } from './actions'; @@ -94,6 +95,7 @@ import { updateTimelineTitle, upsertTimelineColumn, updateSavedQuery, + updateGraphEventId, updateFilters, updateTimelineEventType, } from './helpers'; @@ -194,6 +196,10 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateTimelineShowTimeline({ id, show, timelineById: state.timelineById }), })) + .case(updateTimelineGraphEventId, (state, { id, graphEventId }) => ({ + ...state, + timelineById: updateGraphEventId({ id, graphEventId, timelineById: state.timelineById }), + })) .case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({ ...state, timelineById: applyDeltaToTimelineColumnWidth({ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 5262c72a6140c9..65798648f92c63 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -23,6 +23,7 @@ export interface TimelineById { } export interface InsertTimeline { + graphEventId?: string; timelineId: string; timelineSavedObjectId: string | null; timelineTitle: string; diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts index 9bf55cfe1ed2a0..52011e14167173 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts @@ -60,6 +60,10 @@ export const ecsSchema = gql` sequence: ToStringArray } + type AgentEcsField { + type: ToStringArray + } + type AuditdData { acct: ToStringArray terminal: ToStringArray @@ -110,6 +114,7 @@ export const ecsSchema = gql` name: ToStringArray ppid: ToNumberArray args: ToStringArray + entity_id: ToStringArray executable: ToStringArray title: ToStringArray thread: Thread @@ -425,6 +430,7 @@ export const ecsSchema = gql` type ECS { _id: String! _index: String + agent: AgentEcsField auditd: AuditdEcsFields destination: DestinationEcsFields dns: DnsEcsFields diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 4a063647a183d9..40666b61939280 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -765,6 +765,8 @@ export interface Ecs { _index?: Maybe; + agent?: Maybe; + auditd?: Maybe; destination?: Maybe; @@ -812,6 +814,10 @@ export interface Ecs { system?: Maybe; } +export interface AgentEcsField { + type?: Maybe; +} + export interface AuditdEcsFields { result?: Maybe; @@ -1267,6 +1273,8 @@ export interface ProcessEcsFields { args?: Maybe; + entity_id?: Maybe; + executable?: Maybe; title?: Maybe; @@ -4083,6 +4091,8 @@ export namespace EcsResolvers { _index?: _IndexResolver, TypeParent, TContext>; + agent?: AgentResolver, TypeParent, TContext>; + auditd?: AuditdResolver, TypeParent, TContext>; destination?: DestinationResolver, TypeParent, TContext>; @@ -4140,6 +4150,11 @@ export namespace EcsResolvers { Parent, TContext >; + export type AgentResolver< + R = Maybe, + Parent = Ecs, + TContext = SiemContext + > = Resolver; export type AuditdResolver< R = Maybe, Parent = Ecs, @@ -4257,6 +4272,18 @@ export namespace EcsResolvers { > = Resolver; } +export namespace AgentEcsFieldResolvers { + export interface Resolvers { + type?: TypeResolver, TypeParent, TContext>; + } + + export type TypeResolver< + R = Maybe, + Parent = AgentEcsField, + TContext = SiemContext + > = Resolver; +} + export namespace AuditdEcsFieldsResolvers { export interface Resolvers { result?: ResultResolver, TypeParent, TContext>; @@ -5761,6 +5788,8 @@ export namespace ProcessEcsFieldsResolvers { args?: ArgsResolver, TypeParent, TContext>; + entity_id?: EntityIdResolver, TypeParent, TContext>; + executable?: ExecutableResolver, TypeParent, TContext>; title?: TitleResolver, TypeParent, TContext>; @@ -5795,6 +5824,11 @@ export namespace ProcessEcsFieldsResolvers { Parent = ProcessEcsFields, TContext = SiemContext > = Resolver; + export type EntityIdResolver< + R = Maybe, + Parent = ProcessEcsFields, + TContext = SiemContext + > = Resolver; export type ExecutableResolver< R = Maybe, Parent = ProcessEcsFields, @@ -9110,6 +9144,7 @@ export type IResolvers = { TimelineItem?: TimelineItemResolvers.Resolvers; TimelineNonEcsData?: TimelineNonEcsDataResolvers.Resolvers; Ecs?: EcsResolvers.Resolvers; + AgentEcsField?: AgentEcsFieldResolvers.Resolvers; AuditdEcsFields?: AuditdEcsFieldsResolvers.Resolvers; AuditdData?: AuditdDataResolvers.Resolvers; Summary?: SummaryResolvers.Resolvers; diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts index f2662c79d33937..ff474c4a841f62 100644 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts +++ b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts @@ -76,12 +76,17 @@ export const processFieldsMap: Readonly> = { 'process.name': 'process.name', 'process.ppid': 'process.ppid', 'process.args': 'process.args', + 'process.entity_id': 'process.entity_id', 'process.executable': 'process.executable', 'process.title': 'process.title', 'process.thread': 'process.thread', 'process.working_directory': 'process.working_directory', }; +export const agentFieldsMap: Readonly> = { + 'agent.type': 'agent.type', +}; + export const userFieldsMap: Readonly> = { 'user.domain': 'user.domain', 'user.id': 'user.id', @@ -327,6 +332,7 @@ export const eventFieldsMap: Readonly> = { timestamp: '@timestamp', '@timestamp': '@timestamp', message: 'message', + ...{ ...agentFieldsMap }, ...{ ...auditdMap }, ...{ ...destinationFieldsMap }, ...{ ...dnsFieldsMap }, From 5c8df21ca0dc034d8f19f6a7936a9360f6e14e46 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Fri, 26 Jun 2020 17:38:02 -0400 Subject: [PATCH 02/38] Hide unused resolver buttons (#70112) Co-authored-by: Elastic Machine --- .../public/resolver/store/actions.ts | 10 ------ .../public/resolver/view/index.tsx | 4 +-- .../resolver/view/process_event_dot.tsx | 33 ++++++------------- .../public/resolver/view/submenu.tsx | 5 --- 4 files changed, 12 insertions(+), 40 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index c633d791e8bf26..ae302d0e609116 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -141,15 +141,6 @@ interface UserSelectedRelatedEventCategory { }; } -/** - * This action should dispatch to indicate that the user chose to focus - * on examining alerts related to a particular ResolverEvent - */ -interface UserSelectedRelatedAlerts { - readonly type: 'userSelectedRelatedAlerts'; - readonly payload: ResolverEvent; -} - export type ResolverAction = | CameraAction | DataAction @@ -160,7 +151,6 @@ export type ResolverAction = | UserSelectedResolverNode | UserRequestedRelatedEventData | UserSelectedRelatedEventCategory - | UserSelectedRelatedAlerts | AppDetectedNewIdFromQueryParams | AppDisplayedDifferentPanel | AppDetectedMissingEventData; diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 9b7114b56495c7..5c188fdc711560 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -136,7 +136,7 @@ export const Resolver = React.memo(function Resolver({ projectionMatrix={projectionMatrix} /> ))} - {[...processNodePositions].map(([processEvent, position], index) => { + {[...processNodePositions].map(([processEvent, position]) => { const adjacentNodeMap = processToAdjacencyMap.get(processEvent); const processEntityId = entityId(processEvent); if (!adjacentNodeMap) { @@ -145,7 +145,7 @@ export const Resolver = React.memo(function Resolver({ } return ( { - dispatch({ - type: 'userSelectedRelatedAlerts', - payload: event, - }); - }, [dispatch, event]); - const history = useHistory(); const urlSearch = history.location.search; @@ -637,22 +630,16 @@ const ProcessEventDotComponents = React.memo( }} > - - - - + {grandTotal > 0 && ( + + )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 8f972dd737af6a..d3bb6123ce04da 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -31,11 +31,6 @@ export const subMenuAssets = { menuError: i18n.translate('xpack.securitySolution.endpoint.resolver.relatedRetrievalError', { defaultMessage: 'There was an error retrieving related events.', }), - relatedAlerts: { - title: i18n.translate('xpack.securitySolution.endpoint.resolver.relatedAlerts', { - defaultMessage: 'Related Alerts', - }), - }, relatedEvents: { title: i18n.translate('xpack.securitySolution.endpoint.resolver.relatedEvents', { defaultMessage: 'Events', From 5236335d63575e5c5c988e7f2bbd3b14270567cd Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Fri, 26 Jun 2020 18:08:07 -0400 Subject: [PATCH 03/38] [Endpoint] Add Endpoint empty states for onboarding (#69626) --- .../hooks/use_intra_app_state.tsx | 6 +- .../agent_config/components/actions_menu.tsx | 184 ++++++------ .../agent_config/details_page/index.tsx | 27 +- .../types/intra_app_route_state.ts | 12 +- .../components/management_empty_state.tsx | 277 ++++++++++++++++++ .../pages/endpoint_hosts/store/action.ts | 42 ++- .../pages/endpoint_hosts/store/index.test.ts | 4 + .../pages/endpoint_hosts/store/middleware.ts | 77 ++++- .../pages/endpoint_hosts/store/reducer.ts | 40 +++ .../pages/endpoint_hosts/store/selectors.ts | 13 + .../management/pages/endpoint_hosts/types.ts | 10 + .../pages/endpoint_hosts/view/index.test.tsx | 52 +++- .../pages/endpoint_hosts/view/index.tsx | 150 ++++++++-- .../pages/policy/view/policy_list.tsx | 118 +------- 14 files changed, 783 insertions(+), 229 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_intra_app_state.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_intra_app_state.tsx index 565c5b364893cc..7bccd3a4b1f584 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_intra_app_state.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_intra_app_state.tsx @@ -28,10 +28,11 @@ export const IntraAppStateProvider = memo<{ }>(({ kibanaScopedHistory, children }) => { const internalAppToAppState = useMemo(() => { return { - forRoute: kibanaScopedHistory.location.hash.substr(1), + forRoute: new URL(`${kibanaScopedHistory.location.hash.substr(1)}`, 'http://localhost') + .pathname, routeState: kibanaScopedHistory.location.state as AnyIntraAppRouteState, }; - }, [kibanaScopedHistory.location.hash, kibanaScopedHistory.location.state]); + }, [kibanaScopedHistory.location.state, kibanaScopedHistory.location.hash]); return ( {children} @@ -57,6 +58,7 @@ export function useIntraAppState(): // once so that it does not impact navigation to the page from within the // ingest app. side affect is that the browser back button would not work // consistently either. + if (location.pathname === intraAppState.forRoute && !wasHandled.has(intraAppState)) { wasHandled.add(intraAppState); return intraAppState.routeState as S; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx index 39fe090e5008cf..86d191d4ff9048 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useState } from 'react'; +import React, { memo, useState, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiContextMenuItem, EuiPortal } from '@elastic/eui'; import { AgentConfig } from '../../../types'; @@ -17,86 +17,106 @@ export const AgentConfigActionMenu = memo<{ config: AgentConfig; onCopySuccess?: (newAgentConfig: AgentConfig) => void; fullButton?: boolean; -}>(({ config, onCopySuccess, fullButton = false }) => { - const hasWriteCapabilities = useCapabilities().write; - const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false); - const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + enrollmentFlyoutOpenByDefault?: boolean; + onCancelEnrollment?: () => void; +}>( + ({ + config, + onCopySuccess, + fullButton = false, + enrollmentFlyoutOpenByDefault = false, + onCancelEnrollment, + }) => { + const hasWriteCapabilities = useCapabilities().write; + const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false); + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState( + enrollmentFlyoutOpenByDefault + ); - return ( - - {(copyAgentConfigPrompt) => { - return ( - <> - {isYamlFlyoutOpen ? ( - - setIsYamlFlyoutOpen(false)} /> - - ) : null} - {isEnrollmentFlyoutOpen && ( - - setIsEnrollmentFlyoutOpen(false)} - /> - - )} - - ), - } - : undefined - } - items={[ - setIsEnrollmentFlyoutOpen(true)} - key="enrollAgents" - > - - , - setIsYamlFlyoutOpen(!isYamlFlyoutOpen)} - key="viewConfig" - > - - , - { - copyAgentConfigPrompt(config, onCopySuccess); - }} - key="copyConfig" - > - { + if (onCancelEnrollment) { + return onCancelEnrollment; + } else { + return () => setIsEnrollmentFlyoutOpen(false); + } + }, [onCancelEnrollment, setIsEnrollmentFlyoutOpen]); + + return ( + + {(copyAgentConfigPrompt) => { + return ( + <> + {isYamlFlyoutOpen ? ( + + setIsYamlFlyoutOpen(false)} /> - , - ]} - /> - - ); - }} - - ); -}); + + ) : null} + {isEnrollmentFlyoutOpen && ( + + + + )} + + ), + } + : undefined + } + items={[ + setIsEnrollmentFlyoutOpen(true)} + key="enrollAgents" + > + + , + setIsYamlFlyoutOpen(!isYamlFlyoutOpen)} + key="viewConfig" + > + + , + { + copyAgentConfigPrompt(config, onCopySuccess); + }} + key="copyConfig" + > + + , + ]} + /> + + ); + }} + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index 410c0fcb2d140a..eaa161d57bbe41 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -3,8 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; -import { Redirect, useRouteMatch, Switch, Route, useHistory } from 'react-router-dom'; +import React, { useMemo, useState, useCallback } from 'react'; +import { Redirect, useRouteMatch, Switch, Route, useHistory, useLocation } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { @@ -21,14 +21,15 @@ import { } from '@elastic/eui'; import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; import styled from 'styled-components'; -import { AgentConfig } from '../../../types'; +import { AgentConfig, AgentConfigDetailsDeployAgentAction } from '../../../types'; import { PAGE_ROUTING_PATHS } from '../../../constants'; -import { useGetOneAgentConfig, useLink, useBreadcrumbs } from '../../../hooks'; +import { useGetOneAgentConfig, useLink, useBreadcrumbs, useCore } from '../../../hooks'; import { Loading } from '../../../components'; import { WithHeaderLayout } from '../../../layouts'; import { ConfigRefreshContext, useGetAgentStatus, AgentStatusRefreshContext } from './hooks'; import { LinkedAgentCount, AgentConfigActionMenu } from '../components'; import { ConfigDatasourcesView, ConfigSettingsView } from './components'; +import { useIntraAppState } from '../../../hooks/use_intra_app_state'; const Divider = styled.div` width: 0; @@ -48,7 +49,13 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { const [redirectToAgentConfigList] = useState(false); const agentStatusRequest = useGetAgentStatus(configId); const { refreshAgentStatus } = agentStatusRequest; + const { + application: { navigateToApp }, + } = useCore(); + const routeState = useIntraAppState(); const agentStatus = agentStatusRequest.data?.results; + const queryParams = new URLSearchParams(useLocation().search); + const openEnrollmentFlyoutOpenByDefault = queryParams.get('openEnrollmentFlyout') === 'true'; const headerLeftContent = useMemo( () => ( @@ -95,6 +102,12 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { [getHref, agentConfig, configId] ); + const enrollmentCancelClickHandler = useCallback(() => { + if (routeState && routeState.onDoneNavigateTo) { + navigateToApp(routeState.onDoneNavigateTo[0], routeState.onDoneNavigateTo[1]); + } + }, [routeState, navigateToApp]); + const headerRightContent = useMemo( () => ( @@ -155,6 +168,12 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { onCopySuccess={(newAgentConfig: AgentConfig) => { history.push(getPath('configuration_details', { configId: newAgentConfig.id })); }} + enrollmentFlyoutOpenByDefault={openEnrollmentFlyoutOpenByDefault} + onCancelEnrollment={ + routeState && routeState.onDoneNavigateTo + ? enrollmentCancelClickHandler + : undefined + } /> ), }, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts index 6e85d12f718910..b2948686ff6e57 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts @@ -21,7 +21,17 @@ export interface CreateDatasourceRouteState { onCancelUrl?: string; } +/** + * Supported routing state for the agent config details page routes with deploy agents action + */ +export interface AgentConfigDetailsDeployAgentAction { + /** On done, navigate to the given app */ + onDoneNavigateTo?: Parameters; +} + /** * All possible Route states. */ -export type AnyIntraAppRouteState = CreateDatasourceRouteState; +export type AnyIntraAppRouteState = + | CreateDatasourceRouteState + | AgentConfigDetailsDeployAgentAction; diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx new file mode 100644 index 00000000000000..5dd47d4e880287 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, MouseEvent, CSSProperties } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButton, + EuiSteps, + EuiTitle, + EuiSelectable, + EuiSelectableMessage, + EuiSelectableProps, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({ + textAlign: 'center', +}); + +interface ManagementStep { + title: string; + children: JSX.Element; +} + +const PolicyEmptyState = React.memo<{ + loading: boolean; + onActionClick: (event: MouseEvent) => void; + actionDisabled?: boolean; +}>(({ loading, onActionClick, actionDisabled }) => { + const policySteps = useMemo( + () => [ + { + title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepOneTitle', { + defaultMessage: 'Head over to Ingest Manager.', + }), + children: ( + + + + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepTwoTitle', { + defaultMessage: 'We’ll create a recommended security policy for you.', + }), + children: ( + + + + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepThreeTitle', { + defaultMessage: 'Enroll your agents through Fleet.', + }), + children: ( + + + + ), + }, + ], + [] + ); + + return ( + + } + bodyComponent={ + + } + /> + ); +}); + +const EndpointsEmptyState = React.memo<{ + loading: boolean; + onActionClick: (event: MouseEvent) => void; + actionDisabled: boolean; + handleSelectableOnChange: (o: EuiSelectableProps['options']) => void; + selectionOptions: EuiSelectableProps['options']; +}>(({ loading, onActionClick, actionDisabled, handleSelectableOnChange, selectionOptions }) => { + const policySteps = useMemo( + () => [ + { + title: i18n.translate('xpack.securitySolution.endpoint.endpointList.stepOneTitle', { + defaultMessage: 'Select a policy you created from the list below.', + }), + children: ( + <> + + + + + + {(list) => { + return loading ? ( + + + + ) : selectionOptions.length ? ( + list + ) : ( + + ); + }} + + + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.endpointList.stepTwoTitle', { + defaultMessage: + 'Head over to Ingest to deploy your Agent with Endpoint Security enabled.', + }), + children: ( + + + + ), + }, + ], + [selectionOptions, handleSelectableOnChange, loading] + ); + + return ( + + } + bodyComponent={ + + } + /> + ); +}); + +const ManagementEmptyState = React.memo<{ + loading: boolean; + onActionClick?: (event: MouseEvent) => void; + actionDisabled?: boolean; + actionButton?: JSX.Element; + dataTestSubj: string; + steps?: ManagementStep[]; + headerComponent: JSX.Element; + bodyComponent: JSX.Element; +}>( + ({ + loading, + onActionClick, + actionDisabled, + dataTestSubj, + steps, + actionButton, + headerComponent, + bodyComponent, + }) => { + return ( +
+ {loading ? ( + + + + + + ) : ( + <> + + +

{headerComponent}

+
+ + + {bodyComponent} + + + {steps && ( + + + + + + )} + + + <> + {actionButton ? ( + actionButton + ) : ( + + + + )} + + + + + )} +
+ ); + } +); + +PolicyEmptyState.displayName = 'PolicyEmptyState'; +EndpointsEmptyState.displayName = 'EndpointsEmptyState'; +ManagementEmptyState.displayName = 'ManagementEmptyState'; + +export { PolicyEmptyState, EndpointsEmptyState, ManagementEmptyState }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 62a2d9e3205c26..4c01b3644cf635 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -10,6 +10,8 @@ import { GetHostPolicyResponse, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; +import { GetPolicyListResponse } from '../../policy/types'; +import { GetPackagesResponse } from '../../../../../../ingest_manager/common'; interface ServerReturnedHostList { type: 'serverReturnedHostList'; @@ -41,10 +43,48 @@ interface ServerFailedToReturnHostPolicyResponse { payload: ServerApiError; } +interface ServerReturnedPoliciesForOnboarding { + type: 'serverReturnedPoliciesForOnboarding'; + payload: { + policyItems: GetPolicyListResponse['items']; + }; +} + +interface ServerFailedToReturnPoliciesForOnboarding { + type: 'serverFailedToReturnPoliciesForOnboarding'; + payload: ServerApiError; +} + +interface UserSelectedEndpointPolicy { + type: 'userSelectedEndpointPolicy'; + payload: { + selectedPolicyId: string; + }; +} + +interface ServerCancelledHostListLoading { + type: 'serverCancelledHostListLoading'; +} + +interface ServerCancelledPolicyItemsLoading { + type: 'serverCancelledPolicyItemsLoading'; +} + +interface ServerReturnedEndpointPackageInfo { + type: 'serverReturnedEndpointPackageInfo'; + payload: GetPackagesResponse['response'][0]; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList | ServerReturnedHostDetails | ServerFailedToReturnHostDetails | ServerReturnedHostPolicyResponse - | ServerFailedToReturnHostPolicyResponse; + | ServerFailedToReturnHostPolicyResponse + | ServerReturnedPoliciesForOnboarding + | ServerFailedToReturnPoliciesForOnboarding + | UserSelectedEndpointPolicy + | ServerCancelledHostListLoading + | ServerCancelledPolicyItemsLoading + | ServerReturnedEndpointPackageInfo; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 71452993abf07e..f2c205661b32ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -46,6 +46,10 @@ describe('HostList store concerns', () => { policyResponseLoading: false, policyResponseError: undefined, location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 85667c9f9fc376..ce164318fdadcc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -5,9 +5,20 @@ */ import { HostResultList } from '../../../../../common/endpoint/types'; +import { GetPolicyListResponse } from '../../policy/types'; import { ImmutableMiddlewareFactory } from '../../../../common/store'; -import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors'; +import { + isOnHostPage, + hasSelectedHost, + uiQueryParams, + listData, + endpointPackageInfo, +} from './selectors'; import { HostState } from '../types'; +import { + sendGetEndpointSpecificDatasources, + sendGetEndpointSecurityPackage, +} from '../../policy/store/policy_list/services/ingest'; export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (coreStart) => { return ({ getState, dispatch }) => (next) => async (action) => { @@ -18,17 +29,34 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor isOnHostPage(state) && hasSelectedHost(state) !== true ) { + if (!endpointPackageInfo(state)) { + sendGetEndpointSecurityPackage(coreStart.http) + .then((packageInfo) => { + dispatch({ + type: 'serverReturnedEndpointPackageInfo', + payload: packageInfo, + }); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + } + const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); + let hostResponse; + try { - const response = await coreStart.http.post('/api/endpoint/metadata', { + hostResponse = await coreStart.http.post('/api/endpoint/metadata', { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], }), }); - response.request_page_index = Number(pageIndex); + hostResponse.request_page_index = Number(pageIndex); + dispatch({ type: 'serverReturnedHostList', - payload: response, + payload: hostResponse, }); } catch (error) { dispatch({ @@ -36,8 +64,45 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor payload: error, }); } + + // No hosts, so we should check to see if there are policies for onboarding + if (hostResponse && hostResponse.hosts.length === 0) { + const http = coreStart.http; + try { + const policyDataResponse: GetPolicyListResponse = await sendGetEndpointSpecificDatasources( + http, + { + query: { + perPage: 50, // Since this is an oboarding flow, we'll cap at 50 policies. + page: 1, + }, + } + ); + + dispatch({ + type: 'serverReturnedPoliciesForOnboarding', + payload: { + policyItems: policyDataResponse.items, + }, + }); + } catch (error) { + dispatch({ + type: 'serverFailedToReturnPoliciesForOnboarding', + payload: error.body ?? error, + }); + return; + } + } else { + dispatch({ + type: 'serverCancelledPolicyItemsLoading', + }); + } } if (action.type === 'userChangedUrl' && hasSelectedHost(state) === true) { + dispatch({ + type: 'serverCancelledPolicyItemsLoading', + }); + // If user navigated directly to a host details page, load the host list if (listData(state).length === 0) { const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); @@ -59,6 +124,10 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor }); return; } + } else { + dispatch({ + type: 'serverCancelledHostListLoading', + }); } // call the host details api diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 23682544ec423e..993267cf1a7041 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -24,8 +24,13 @@ export const initialHostListState: Immutable = { policyResponseLoading: false, policyResponseError: undefined, location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, }; +/* eslint-disable-next-line complexity */ export const hostListReducer: ImmutableReducer = ( state = initialHostListState, action @@ -65,6 +70,18 @@ export const hostListReducer: ImmutableReducer = ( detailsError: action.payload, detailsLoading: false, }; + } else if (action.type === 'serverReturnedPoliciesForOnboarding') { + return { + ...state, + policyItems: action.payload.policyItems, + policyItemsLoading: false, + }; + } else if (action.type === 'serverFailedToReturnPoliciesForOnboarding') { + return { + ...state, + error: action.payload, + policyItemsLoading: false, + }; } else if (action.type === 'serverReturnedHostPolicyResponse') { return { ...state, @@ -78,6 +95,27 @@ export const hostListReducer: ImmutableReducer = ( policyResponseError: action.payload, policyResponseLoading: false, }; + } else if (action.type === 'userSelectedEndpointPolicy') { + return { + ...state, + selectedPolicyId: action.payload.selectedPolicyId, + policyResponseLoading: false, + }; + } else if (action.type === 'serverCancelledHostListLoading') { + return { + ...state, + loading: false, + }; + } else if (action.type === 'serverCancelledPolicyItemsLoading') { + return { + ...state, + policyItemsLoading: false, + }; + } else if (action.type === 'serverReturnedEndpointPackageInfo') { + return { + ...state, + endpointPackageInfo: action.payload, + }; } else if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, @@ -95,6 +133,7 @@ export const hostListReducer: ImmutableReducer = ( ...state, location: action.payload, loading: true, + policyItemsLoading: true, error: undefined, detailsError: undefined, }; @@ -122,6 +161,7 @@ export const hostListReducer: ImmutableReducer = ( error: undefined, detailsError: undefined, policyResponseError: undefined, + policyItemsLoading: true, }; } } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 20365b3fe100b9..e75d2129f61a54 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -37,6 +37,19 @@ export const detailsLoading = (state: Immutable): boolean => state.de export const detailsError = (state: Immutable) => state.detailsError; +export const policyItems = (state: Immutable) => state.policyItems; + +export const policyItemsLoading = (state: Immutable) => state.policyItemsLoading; + +export const selectedPolicyId = (state: Immutable) => state.selectedPolicyId; + +export const endpointPackageInfo = (state: Immutable) => state.endpointPackageInfo; + +export const endpointPackageVersion = createSelector( + endpointPackageInfo, + (info) => info?.version ?? undefined +); + /** * Returns the full policy response from the endpoint after a user modifies a policy. */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 4881342c065737..a5f37a0b49e8f7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -10,8 +10,10 @@ import { HostMetadata, HostPolicyResponse, AppLocation, + PolicyData, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; +import { GetPackagesResponse } from '../../../../../ingest_manager/common'; export interface HostState { /** list of host **/ @@ -40,6 +42,14 @@ export interface HostState { policyResponseError?: ServerApiError; /** current location info */ location?: Immutable; + /** policies */ + policyItems: PolicyData[]; + /** policies are loading */ + policyItemsLoading: boolean; + /** the selected policy ID in the onboarding flow */ + selectedPolicyId?: string; + /** Endpoint package info */ + endpointPackageInfo?: GetPackagesResponse['response'][0]; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 7bc101b8914775..9690ac5c1b9bf9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -40,12 +40,60 @@ describe('when on the hosts page', () => { expect(timelineFlyout).toBeNull(); }); - it('should show a table', async () => { + it('should show the empty state when there are no hosts or polices', async () => { const renderResult = render(); - const table = await renderResult.findByTestId('hostListTable'); + // Initially, there are no endpoints or policies, so we prompt to add policies first. + const table = await renderResult.findByTestId('emptyPolicyTable'); expect(table).not.toBeNull(); }); + describe('when there are policies, but no hosts', () => { + beforeEach(() => { + reactTestingLibrary.act(() => { + const hostListData = mockHostResultList({ total: 0 }); + coreStart.http.get.mockReturnValue(Promise.resolve(hostListData)); + const hostAction: AppAction = { + type: 'serverReturnedHostList', + payload: hostListData, + }; + store.dispatch(hostAction); + + jest.clearAllMocks(); + + const policyListData = mockPolicyResultList({ total: 3 }); + coreStart.http.get.mockReturnValue(Promise.resolve(policyListData)); + const policyAction: AppAction = { + type: 'serverReturnedPoliciesForOnboarding', + payload: { + policyItems: policyListData.items, + }, + }; + store.dispatch(policyAction); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should show the no hosts empty state', async () => { + const renderResult = render(); + const emptyEndpointsTable = await renderResult.findByTestId('emptyEndpointsTable'); + expect(emptyEndpointsTable).not.toBeNull(); + }); + + it('should display the onboarding steps', async () => { + const renderResult = render(); + const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); + expect(onboardingSteps).not.toBeNull(); + }); + + it('should show policy selection', async () => { + const renderResult = render(); + const onboardingPolicySelect = await renderResult.findByTestId('onboardingPolicySelect'); + expect(onboardingPolicySelect).not.toBeNull(); + }); + }); + describe('when there is no selected host in the url', () => { it('should not show the flyout', () => { const renderResult = render(); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 45a33f76ee0c59..3601b8db5ee592 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -13,12 +13,13 @@ import { EuiLink, EuiHealth, EuiToolTip, + EuiSelectableProps, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; - +import { useDispatch } from 'react-redux'; import { HostDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useHostSelector } from './hooks'; @@ -32,7 +33,13 @@ import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { ManagementPageView } from '../../../components/management_page_view'; +import { PolicyEmptyState, EndpointsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; +import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { + CreateDatasourceRouteState, + AgentConfigDetailsDeployAgentAction, +} from '../../../../../../ingest_manager/public'; import { SecurityPageName } from '../../../../app/types'; import { getEndpointListPath, @@ -40,6 +47,7 @@ import { getPolicyDetailPath, } from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; +import { HostAction } from '../store/action'; const HostListNavLink = memo<{ name: string; @@ -75,9 +83,15 @@ export const HostList = () => { listError, uiQueryParams: queryParams, hasSelectedHost, + policyItems, + selectedPolicyId, + policyItemsLoading, + endpointPackageVersion, } = useHostSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const dispatch = useDispatch<(a: HostAction) => void>(); + const paginationSetup = useMemo(() => { return { pageIndex, @@ -104,6 +118,67 @@ export const HostList = () => { [history, queryParams] ); + const handleCreatePolicyClick = useNavigateToAppEventHandler( + 'ingestManager', + { + path: `#/integrations${ + endpointPackageVersion ? `/endpoint-${endpointPackageVersion}/add-datasource` : '' + }`, + state: { + onCancelNavigateTo: [ + 'securitySolution:management', + { path: getEndpointListPath({ name: 'endpointList' }) }, + ], + onCancelUrl: formatUrl(getEndpointListPath({ name: 'endpointList' })), + onSaveNavigateTo: [ + 'securitySolution:management', + { path: getEndpointListPath({ name: 'endpointList' }) }, + ], + }, + } + ); + + const handleDeployEndpointsClick = useNavigateToAppEventHandler< + AgentConfigDetailsDeployAgentAction + >('ingestManager', { + path: `#/configs/${selectedPolicyId}?openEnrollmentFlyout=true`, + state: { + onDoneNavigateTo: [ + 'securitySolution:management', + { path: getEndpointListPath({ name: 'endpointList' }) }, + ], + }, + }); + + const selectionOptions = useMemo(() => { + return policyItems.map((item) => { + return { + key: item.config_id, + label: item.name, + checked: selectedPolicyId === item.config_id ? 'on' : undefined, + }; + }); + }, [policyItems, selectedPolicyId]); + + const handleSelectableOnChange = useCallback<(o: EuiSelectableProps['options']) => void>( + (changedOptions) => { + return changedOptions.some((option) => { + if ('checked' in option && option.checked === 'on') { + dispatch({ + type: 'userSelectedEndpointPolicy', + payload: { + selectedPolicyId: option.key as string, + }, + }); + return true; + } else { + return false; + } + }); + }, + [dispatch] + ); + const columns: Array>> = useMemo(() => { const lastActiveColumnName = i18n.translate('xpack.securitySolution.endpointList.lastActive', { defaultMessage: 'Last Active', @@ -252,6 +327,49 @@ export const HostList = () => { ]; }, [formatUrl, queryParams, search]); + const renderTableOrEmptyState = useMemo(() => { + if (!loading && listData && listData.length > 0) { + return ( + + ); + } else if (!policyItemsLoading && policyItems && policyItems.length > 0) { + return ( + + ); + } else { + return ( + + ); + } + }, [ + listData, + policyItems, + columns, + loading, + paginationSetup, + onTableChange, + listError?.message, + handleCreatePolicyClick, + handleDeployEndpointsClick, + handleSelectableOnChange, + selectedPolicyId, + selectionOptions, + policyItemsLoading, + ]); + return ( { })} > {hasSelectedHost && } - - - - - [...listData], [listData])} - columns={columns} - loading={loading} - error={listError?.message} - pagination={paginationSetup} - onChange={onTableChange} - /> + {listData && listData.length > 0 && ( + <> + + + + + + )} + {renderTableOrEmptyState} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 26b6ecb540cd9c..8a760334c53af4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, CSSProperties, useState, MouseEvent } from 'react'; +import React, { useCallback, useEffect, useMemo, CSSProperties, useState } from 'react'; import { EuiBasicTable, EuiText, @@ -22,9 +22,6 @@ import { EuiCallOut, EuiSpacer, EuiButton, - EuiSteps, - EuiTitle, - EuiProgress, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -41,6 +38,7 @@ import { Immutable, PolicyData } from '../../../../../common/endpoint/types'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { ManagementPageView } from '../../../components/management_page_view'; +import { PolicyEmptyState } from '../../../components/management_empty_state'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { FormattedDateAndTime } from '../../../../common/components/endpoint/formatted_date_time'; import { SecurityPageName } from '../../../../app/types'; @@ -65,10 +63,6 @@ const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ whiteSpace: 'nowrap', }); -const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({ - textAlign: 'center', -}); - const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` color: ${(props) => props.theme.eui.textColors.danger}; `; @@ -437,12 +431,7 @@ export const PolicyList = React.memo(() => { hasActions={false} /> ) : ( - + )} ); @@ -462,107 +451,6 @@ export const PolicyList = React.memo(() => { PolicyList.displayName = 'PolicyList'; -const EmptyPolicyTable = React.memo<{ - loading: boolean; - onActionClick: (event: MouseEvent) => void; - actionDisabled: boolean; - dataTestSubj: string; -}>(({ loading, onActionClick, actionDisabled, dataTestSubj }) => { - const policySteps = useMemo( - () => [ - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepOneTitle', { - defaultMessage: 'Head over to Ingest Manager.', - }), - children: ( - - - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepTwoTitle', { - defaultMessage: 'We’ll create a recommended security policy for you.', - }), - children: ( - - - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.policyList.stepThreeTitle', { - defaultMessage: 'Enroll your agents through Fleet.', - }), - children: ( - - - - ), - }, - ], - [] - ); - return ( -
- {loading ? ( - - ) : ( - <> - - -

- -

-
- - - - - - - - - - - - - - - - - - - )} -
- ); -}); - -EmptyPolicyTable.displayName = 'EmptyPolicyTable'; - const ConfirmDelete = React.memo<{ hostCount: number; isDeleting: boolean; From 266f853b0bde6169fbe6622aca2146380bb8cbe9 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Sat, 27 Jun 2020 02:52:26 +0300 Subject: [PATCH 04/38] [Telemetry] Collector Schema (#64942) Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 6 + .telemetryrc.json | 25 ++ package.json | 1 + packages/kbn-telemetry-tools/README.md | 89 +++++++ packages/kbn-telemetry-tools/babel.config.js | 23 ++ packages/kbn-telemetry-tools/package.json | 22 ++ .../src/cli/run_telemetry_check.ts | 109 ++++++++ .../src/cli/run_telemetry_extract.ts | 75 ++++++ packages/kbn-telemetry-tools/src/index.ts | 21 ++ .../src/tools/__fixture__/mock_schema.json | 24 ++ .../parsed_externally_defined_collector.ts | 68 +++++ .../__fixture__/parsed_imported_schema.ts | 46 ++++ .../parsed_imported_usage_interface.ts | 46 ++++ .../__fixture__/parsed_nested_collector.ts | 44 ++++ .../__fixture__/parsed_working_collector.ts | 69 +++++ .../extract_collectors.test.ts.snap | 163 ++++++++++++ .../__snapshots__/ts_parser.test.ts.snap | 6 + .../tools/check_collector__integrity.test.ts | 125 +++++++++ .../src/tools/check_collector_integrity.ts | 103 ++++++++ .../src/tools/config.test.ts | 40 +++ .../kbn-telemetry-tools/src/tools/config.ts | 60 +++++ .../src/tools/constants.ts | 20 ++ .../src/tools/extract_collectors.test.ts | 40 +++ .../src/tools/extract_collectors.ts | 75 ++++++ .../src/tools/manage_schema.test.ts | 39 +++ .../src/tools/manage_schema.ts | 86 ++++++ .../src/tools/serializer.test.ts | 105 ++++++++ .../src/tools/serializer.ts | 169 ++++++++++++ .../tasks/check_compatible_types_task.ts | 43 +++ .../tasks/check_matching_schemas_task.ts | 40 +++ .../src/tools/tasks/error_reporter.ts | 34 +++ .../tools/tasks/extract_collectors_task.ts | 58 ++++ .../src/tools/tasks/generate_schemas_task.ts | 35 +++ .../src/tools/tasks/index.ts | 28 ++ .../src/tools/tasks/parse_configs_task.ts | 46 ++++ .../src/tools/tasks/task_context.ts | 41 +++ .../src/tools/tasks/write_to_file_task.ts | 35 +++ .../src/tools/ts_parser.test.ts | 94 +++++++ .../src/tools/ts_parser.ts | 210 +++++++++++++++ .../kbn-telemetry-tools/src/tools/utils.ts | 238 +++++++++++++++++ packages/kbn-telemetry-tools/tsconfig.json | 6 + scripts/telemetry_check.js | 21 ++ scripts/telemetry_extract.js | 21 ++ .../telemetry_collectors/.telemetryrc.json | 7 + .../telemetry_collectors/constants.ts | 53 ++++ .../externally_defined_collector.ts | 71 +++++ .../file_with_no_collector.ts | 20 ++ .../telemetry_collectors/imported_schema.ts | 41 +++ .../imported_usage_interface.ts | 41 +++ .../telemetry_collectors/nested_collector.ts | 49 ++++ .../unmapped_collector.ts | 39 +++ .../telemetry_collectors/working_collector.ts | 81 ++++++ .../csp_usage_collector/csp_collector.test.ts | 15 +- .../lib/csp_usage_collector/csp_collector.ts | 27 +- .../kql_telemetry/usage_collector/fetch.ts | 10 +- .../make_kql_usage_collector.ts | 12 +- .../services/sample_data/usage/collector.ts | 12 +- .../sample_data/usage/collector_fetch.ts | 2 +- .../common/constants.ts | 21 -- .../telemetry_application_usage_collector.ts | 3 +- .../kibana/kibana_usage_collector.ts | 4 +- .../telemetry_management_collector.ts | 3 +- .../telemetry_ui_metric_collector.ts | 3 +- src/plugins/telemetry/common/constants.ts | 5 - .../telemetry/schema/legacy_oss_plugins.json | 17 ++ src/plugins/telemetry/schema/oss_plugins.json | 59 +++++ .../telemetry_plugin_collector.ts | 10 +- src/plugins/usage_collection/README.md | 67 ++++- .../server/collector/collector.ts | 24 ++ .../server/collector/collector_set.ts | 2 +- .../server/collector/index.ts | 8 +- src/plugins/usage_collection/server/index.ts | 7 + .../validation_telemetry_service.ts | 8 +- tasks/config/run.js | 6 + tasks/jenkins.js | 1 + x-pack/.telemetryrc.json | 14 + .../server/usage/actions_usage_collector.ts | 4 +- .../server/usage/alerts_usage_collector.ts | 4 +- x-pack/plugins/canvas/common/lib/constants.ts | 1 - .../canvas/server/collectors/collector.ts | 13 +- x-pack/plugins/cloud/common/constants.ts | 1 - .../collectors/cloud_usage_collector.ts | 12 +- .../telemetry/file_upload_usage_collector.ts | 20 +- .../infra/server/usage/usage_collector.ts | 4 +- .../lib/telemetry/ml_usage_collector.ts | 22 +- .../ml/server/lib/telemetry/telemetry.ts | 2 +- x-pack/plugins/reporting/common/constants.ts | 6 - .../server/usage/reporting_usage_collector.ts | 17 +- .../rollup/server/collectors/register.ts | 35 ++- x-pack/plugins/spaces/common/constants.ts | 6 - .../spaces_usage_collector.ts | 57 +++- .../schema/xpack_plugins.json | 247 ++++++++++++++++++ .../server/lib/telemetry/usage_collector.ts | 24 +- .../telemetry/kibana_telemetry_adapter.ts | 32 ++- .../server/lib/adapters/telemetry/types.ts | 6 + 95 files changed, 3766 insertions(+), 138 deletions(-) create mode 100644 .telemetryrc.json create mode 100644 packages/kbn-telemetry-tools/README.md create mode 100644 packages/kbn-telemetry-tools/babel.config.js create mode 100644 packages/kbn-telemetry-tools/package.json create mode 100644 packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts create mode 100644 packages/kbn-telemetry-tools/src/cli/run_telemetry_extract.ts create mode 100644 packages/kbn-telemetry-tools/src/index.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_externally_defined_collector.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_schema.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_usage_interface.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_nested_collector.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap create mode 100644 packages/kbn-telemetry-tools/src/tools/__snapshots__/ts_parser.test.ts.snap create mode 100644 packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/check_collector_integrity.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/config.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/config.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/constants.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/extract_collectors.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/manage_schema.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/manage_schema.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/serializer.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/serializer.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/check_compatible_types_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/error_reporter.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/extract_collectors_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/generate_schemas_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/index.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/parse_configs_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/task_context.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/tasks/write_to_file_task.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/ts_parser.ts create mode 100644 packages/kbn-telemetry-tools/src/tools/utils.ts create mode 100644 packages/kbn-telemetry-tools/tsconfig.json create mode 100644 scripts/telemetry_check.js create mode 100644 scripts/telemetry_extract.js create mode 100644 src/fixtures/telemetry_collectors/.telemetryrc.json create mode 100644 src/fixtures/telemetry_collectors/constants.ts create mode 100644 src/fixtures/telemetry_collectors/externally_defined_collector.ts create mode 100644 src/fixtures/telemetry_collectors/file_with_no_collector.ts create mode 100644 src/fixtures/telemetry_collectors/imported_schema.ts create mode 100644 src/fixtures/telemetry_collectors/imported_usage_interface.ts create mode 100644 src/fixtures/telemetry_collectors/nested_collector.ts create mode 100644 src/fixtures/telemetry_collectors/unmapped_collector.ts create mode 100644 src/fixtures/telemetry_collectors/working_collector.ts create mode 100644 src/plugins/telemetry/schema/legacy_oss_plugins.json create mode 100644 src/plugins/telemetry/schema/oss_plugins.json create mode 100644 x-pack/.telemetryrc.json create mode 100644 x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e6f6e83253c8bb..47f9942162f75c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -170,6 +170,7 @@ # Kibana Telemetry /packages/kbn-analytics/ @elastic/kibana-telemetry +/packages/kbn-telemetry-tools/ @elastic/kibana-telemetry /src/plugins/kibana_usage_collection/ @elastic/kibana-telemetry /src/plugins/newsfeed/ @elastic/kibana-telemetry /src/plugins/telemetry/ @elastic/kibana-telemetry @@ -177,6 +178,11 @@ /src/plugins/telemetry_management_section/ @elastic/kibana-telemetry /src/plugins/usage_collection/ @elastic/kibana-telemetry /x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-telemetry +/.telemetryrc.json @elastic/kibana-telemetry +/x-pack/.telemetryrc.json @elastic/kibana-telemetry +src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-telemetry +src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-telemetry +x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-telemetry # Kibana Alerting Services /x-pack/plugins/alerts/ @elastic/kibana-alerting-services diff --git a/.telemetryrc.json b/.telemetryrc.json new file mode 100644 index 00000000000000..30643a104c1cd2 --- /dev/null +++ b/.telemetryrc.json @@ -0,0 +1,25 @@ +[ + { + "output": "src/plugins/telemetry/schema/legacy_oss_plugins.json", + "root": "src/legacy/core_plugins/", + "exclude": [ + "src/legacy/core_plugins/testbed", + "src/legacy/core_plugins/elasticsearch", + "src/legacy/core_plugins/tests_bundle" + ] + }, + { + "output": "src/plugins/telemetry/schema/oss_plugins.json", + "root": "src/plugins/", + "exclude": [ + "src/plugins/kibana_react/", + "src/plugins/testbed/", + "src/plugins/kibana_utils/", + "src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts", + "src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts", + "src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts", + "src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts", + "src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts" + ] + } +] diff --git a/package.json b/package.json index 10eaef8ed5dc74..b1202631a0c026 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "@kbn/babel-preset": "1.0.0", "@kbn/config-schema": "1.0.0", "@kbn/i18n": "1.0.0", + "@kbn/telemetry-tools": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", diff --git a/packages/kbn-telemetry-tools/README.md b/packages/kbn-telemetry-tools/README.md new file mode 100644 index 00000000000000..ccd092c76a17c4 --- /dev/null +++ b/packages/kbn-telemetry-tools/README.md @@ -0,0 +1,89 @@ +# Telemetry Tools + +## Schema extraction tool + +### Description + +The tool is used to extract telemetry collectors schema from all `*.{ts}` files in provided plugins directories to JSON files. The tool looks for `.telemetryrc.json` files in the root of the project and in the `x-pack` dir for its runtime configurations. + +It uses typescript parser to build an AST for each file. The tool is able to validate, extract and match collector schemas. + +### Examples and restrictions + +**Global restrictions**: + +The `id` can be only a string literal, it cannot be a template literals w/o expressions or string-only concatenation expressions or anything else. + +``` +export const myCollector = makeUsageCollector({ + type: 'string_literal_only', + ... +}); +``` + +### Usage + +```bash +node scripts/telemetry_extract.js +``` + +This command has no additional flags or arguments. The `.telemetryrc.json` files specify the path to the directory where searching should start, output json files, and files to exclude. + + +### Output + + +The generated JSON files contain an ES mapping for each schema. This mapping is used to verify changes in the collectors and as the basis to map those fields into the external telemetry cluster. + +**Example**: + +```json +{ + "properties": { + "cloud": { + "properties": { + "isCloudEnabled": { + "type": "boolean" + } + } + } + } +} +``` + +## Schema validation tool + +### Description + +The tool performs a number of checks on all telemetry collectors and verifies the following: + +1. Verifies the collector structure, fields, and returned values are using the appropriate types. +2. Verifies that the collector `fetch` function Type matches the specified `schema` in the collector. +3. Verifies that the collector `schema` matches the stored json schema . + +### Notes + +We don't catch every possible misuse of the collectors, but only the most common and critical ones. + +What will not be caught by the validator: + +* Mistyped SavedObject/CallCluster return value. Since the hits returned from ES can be typed to anything without any checks. It is advised to add functional tests that grabs the schema json file and checks that the returned usage matches the types exactly. + +* Fields in the schema that are never collected. If you are trying to report a field from ES but that value is never stored in ES, the check will not be able to detect if that field is ever collected in the first palce. It is advised to add unit/functional tests to check that all the fields are being reported as expected. + +The tool looks for `.telemetryrc.json` files in the root of the project and in the `x-pack` dir for its runtime configurations. + +Currently auto-fixer (`--fix`) can automatically fix the json files with the following errors: + +* incompatible schema - this error means that the collector schema was changed but the stored json schema file was not updated. + +* unused schemas - this error means that a collector was removed or its `type` renamed, the json schema file contains a schema that does not have a corrisponding collector. + +### Usage + +```bash +node scripts/telemetry_check --fix +``` + +* `--path` specifies a collector path instead of checking all collectors specified in the `.telemetryrc.json` files. Accepts a `.ts` file. The file must be discoverable by at least one rc file. +* `--fix` tells the tool to try to fix as many violations as possible. All errors that tool won't be able to fix will be reported. diff --git a/packages/kbn-telemetry-tools/babel.config.js b/packages/kbn-telemetry-tools/babel.config.js new file mode 100644 index 00000000000000..3b09c7d74ccb56 --- /dev/null +++ b/packages/kbn-telemetry-tools/babel.config.js @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = { + presets: ['@kbn/babel-preset/node_preset'], + ignore: ['**/*.test.ts', '**/__fixture__/**'], +}; diff --git a/packages/kbn-telemetry-tools/package.json b/packages/kbn-telemetry-tools/package.json new file mode 100644 index 00000000000000..5593a72ecd965a --- /dev/null +++ b/packages/kbn-telemetry-tools/package.json @@ -0,0 +1,22 @@ +{ + "name": "@kbn/telemetry-tools", + "version": "1.0.0", + "license": "Apache-2.0", + "main": "./target/index.js", + "private": true, + "scripts": { + "build": "babel src --out-dir target --delete-dir-on-start --extensions .ts --source-maps=inline", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + }, + "devDependencies": { + "lodash": "npm:@elastic/lodash@3.10.1-kibana4", + "@kbn/dev-utils": "1.0.0", + "@kbn/utility-types": "1.0.0", + "@types/normalize-path": "^3.0.0", + "normalize-path": "^3.0.0", + "@types/lodash": "^3.10.1", + "moment": "^2.24.0", + "typescript": "3.9.5" + } +} diff --git a/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts b/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts new file mode 100644 index 00000000000000..116c484a5c36af --- /dev/null +++ b/packages/kbn-telemetry-tools/src/cli/run_telemetry_check.ts @@ -0,0 +1,109 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Listr from 'listr'; +import chalk from 'chalk'; +import { createFailError, run } from '@kbn/dev-utils'; + +import { + createTaskContext, + ErrorReporter, + parseConfigsTask, + extractCollectorsTask, + checkMatchingSchemasTask, + generateSchemasTask, + checkCompatibleTypesTask, + writeToFileTask, + TaskContext, +} from '../tools/tasks'; + +export function runTelemetryCheck() { + run( + async ({ flags: { fix = false, path }, log }) => { + if (typeof fix !== 'boolean') { + throw createFailError(`${chalk.white.bgRed(' TELEMETRY ERROR ')} --fix can't have a value`); + } + + if (typeof path === 'boolean') { + throw createFailError(`${chalk.white.bgRed(' TELEMETRY ERROR ')} --path require a value`); + } + + if (fix && typeof path !== 'undefined') { + throw createFailError( + `${chalk.white.bgRed(' TELEMETRY ERROR ')} --fix is incompatible with --path flag.` + ); + } + + const list = new Listr([ + { + title: 'Checking .telemetryrc.json files', + task: () => new Listr(parseConfigsTask(), { exitOnError: true }), + }, + { + title: 'Extracting Collectors', + task: (context) => new Listr(extractCollectorsTask(context, path), { exitOnError: true }), + }, + { + title: 'Checking Compatible collector.schema with collector.fetch type', + task: (context) => new Listr(checkCompatibleTypesTask(context), { exitOnError: true }), + }, + { + title: 'Checking Matching collector.schema against stored json files', + task: (context) => new Listr(checkMatchingSchemasTask(context), { exitOnError: true }), + }, + { + enabled: (_) => fix, + skip: ({ roots }: TaskContext) => { + return roots.every(({ esMappingDiffs }) => !esMappingDiffs || !esMappingDiffs.length); + }, + title: 'Generating new telemetry mappings', + task: (context) => new Listr(generateSchemasTask(context), { exitOnError: true }), + }, + { + enabled: (_) => fix, + skip: ({ roots }: TaskContext) => { + return roots.every(({ esMappingDiffs }) => !esMappingDiffs || !esMappingDiffs.length); + }, + title: 'Updating telemetry mapping files', + task: (context) => new Listr(writeToFileTask(context), { exitOnError: true }), + }, + ]); + + try { + const context = createTaskContext(); + await list.run(context); + } catch (error) { + process.exitCode = 1; + if (error instanceof ErrorReporter) { + error.errors.forEach((e: string | Error) => log.error(e)); + } else { + log.error('Unhandled exception!'); + log.error(error); + } + } + process.exit(); + }, + { + flags: { + allowUnexpected: true, + guessTypesForUnexpectedFlags: true, + }, + } + ); +} diff --git a/packages/kbn-telemetry-tools/src/cli/run_telemetry_extract.ts b/packages/kbn-telemetry-tools/src/cli/run_telemetry_extract.ts new file mode 100644 index 00000000000000..27a406a4e216d2 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/cli/run_telemetry_extract.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Listr from 'listr'; +import { run } from '@kbn/dev-utils'; + +import { + createTaskContext, + ErrorReporter, + parseConfigsTask, + extractCollectorsTask, + generateSchemasTask, + writeToFileTask, +} from '../tools/tasks'; + +export function runTelemetryExtract() { + run( + async ({ flags: {}, log }) => { + const list = new Listr([ + { + title: 'Parsing .telemetryrc.json files', + task: () => new Listr(parseConfigsTask(), { exitOnError: true }), + }, + { + title: 'Extracting Telemetry Collectors', + task: (context) => new Listr(extractCollectorsTask(context), { exitOnError: true }), + }, + { + title: 'Generating Schema files', + task: (context) => new Listr(generateSchemasTask(context), { exitOnError: true }), + }, + { + title: 'Writing to file', + task: (context) => new Listr(writeToFileTask(context), { exitOnError: true }), + }, + ]); + + try { + const context = createTaskContext(); + await list.run(context); + } catch (error) { + process.exitCode = 1; + if (error instanceof ErrorReporter) { + error.errors.forEach((e: string | Error) => log.error(e)); + } else { + log.error('Unhandled exception'); + log.error(error); + } + } + process.exit(); + }, + { + flags: { + allowUnexpected: true, + guessTypesForUnexpectedFlags: true, + }, + } + ); +} diff --git a/packages/kbn-telemetry-tools/src/index.ts b/packages/kbn-telemetry-tools/src/index.ts new file mode 100644 index 00000000000000..3a018a9b3002c3 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { runTelemetryCheck } from './cli/run_telemetry_check'; +export { runTelemetryExtract } from './cli/run_telemetry_extract'; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json new file mode 100644 index 00000000000000..885fe0e38dacfe --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json @@ -0,0 +1,24 @@ +{ + "properties": { + "my_working_collector": { + "properties": { + "flat": { + "type": "keyword" + }, + "my_str": { + "type": "text" + }, + "my_objects": { + "properties": { + "total": { + "type": "number" + }, + "type": { + "type": "boolean" + } + } + } + } + } + } +} diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_externally_defined_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_externally_defined_collector.ts new file mode 100644 index 00000000000000..fe45f6b7f30427 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_externally_defined_collector.ts @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedExternallyDefinedCollector: ParsedUsageCollection[] = [ + [ + 'src/fixtures/telemetry_collectors/externally_defined_collector.ts', + { + collectorName: 'from_variable_collector', + schema: { + value: { + locale: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + locale: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], + [ + 'src/fixtures/telemetry_collectors/externally_defined_collector.ts', + { + collectorName: 'from_fn_collector', + schema: { + value: { + locale: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + locale: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_schema.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_schema.ts new file mode 100644 index 00000000000000..48702520829502 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_schema.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedImportedSchemaCollector: ParsedUsageCollection[] = [ + [ + 'src/fixtures/telemetry_collectors/imported_schema.ts', + { + collectorName: 'with_imported_schema', + schema: { + value: { + locale: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + locale: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_usage_interface.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_usage_interface.ts new file mode 100644 index 00000000000000..42ed2140b5208a --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_imported_usage_interface.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedImportedUsageInterface: ParsedUsageCollection[] = [ + [ + 'src/fixtures/telemetry_collectors/imported_usage_interface.ts', + { + collectorName: 'imported_usage_interface_collector', + schema: { + value: { + locale: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + locale: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, + ], +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_nested_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_nested_collector.ts new file mode 100644 index 00000000000000..ed727c15b7c86e --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_nested_collector.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedNestedCollector: ParsedUsageCollection = [ + 'src/fixtures/telemetry_collectors/nested_collector.ts', + { + collectorName: 'my_nested_collector', + schema: { + value: { + locale: { + type: 'keyword', + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + locale: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + }, + }, + }, +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts new file mode 100644 index 00000000000000..25e49ea221c941 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedWorkingCollector: ParsedUsageCollection = [ + 'src/fixtures/telemetry_collectors/working_collector.ts', + { + collectorName: 'my_working_collector', + schema: { + value: { + flat: { + type: 'keyword', + }, + my_str: { + type: 'text', + }, + my_objects: { + total: { + type: 'number', + }, + type: { + type: 'boolean', + }, + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + flat: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + my_str: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, + my_objects: { + total: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + type: { + kind: SyntaxKind.BooleanKeyword, + type: 'BooleanKeyword', + }, + }, + }, + }, + }, +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap new file mode 100644 index 00000000000000..44a12dfa9030cc --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractCollectors extracts collectors given rc file 1`] = ` +Array [ + Array [ + "src/fixtures/telemetry_collectors/externally_defined_collector.ts", + Object { + "collectorName": "from_variable_collector", + "fetch": Object { + "typeDescriptor": Object { + "locale": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "locale": Object { + "type": "keyword", + }, + }, + }, + }, + ], + Array [ + "src/fixtures/telemetry_collectors/externally_defined_collector.ts", + Object { + "collectorName": "from_fn_collector", + "fetch": Object { + "typeDescriptor": Object { + "locale": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "locale": Object { + "type": "keyword", + }, + }, + }, + }, + ], + Array [ + "src/fixtures/telemetry_collectors/imported_schema.ts", + Object { + "collectorName": "with_imported_schema", + "fetch": Object { + "typeDescriptor": Object { + "locale": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "locale": Object { + "type": "keyword", + }, + }, + }, + }, + ], + Array [ + "src/fixtures/telemetry_collectors/imported_usage_interface.ts", + Object { + "collectorName": "imported_usage_interface_collector", + "fetch": Object { + "typeDescriptor": Object { + "locale": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "locale": Object { + "type": "keyword", + }, + }, + }, + }, + ], + Array [ + "src/fixtures/telemetry_collectors/nested_collector.ts", + Object { + "collectorName": "my_nested_collector", + "fetch": Object { + "typeDescriptor": Object { + "locale": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "locale": Object { + "type": "keyword", + }, + }, + }, + }, + ], + Array [ + "src/fixtures/telemetry_collectors/working_collector.ts", + Object { + "collectorName": "my_working_collector", + "fetch": Object { + "typeDescriptor": Object { + "flat": Object { + "kind": 143, + "type": "StringKeyword", + }, + "my_objects": Object { + "total": Object { + "kind": 140, + "type": "NumberKeyword", + }, + "type": Object { + "kind": 128, + "type": "BooleanKeyword", + }, + }, + "my_str": Object { + "kind": 143, + "type": "StringKeyword", + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "flat": Object { + "type": "keyword", + }, + "my_objects": Object { + "total": Object { + "type": "number", + }, + "type": Object { + "type": "boolean", + }, + }, + "my_str": Object { + "type": "text", + }, + }, + }, + }, + ], +] +`; diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/ts_parser.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/ts_parser.test.ts.snap new file mode 100644 index 00000000000000..5b1b3d9d352990 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__snapshots__/ts_parser.test.ts.snap @@ -0,0 +1,6 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseUsageCollection throws when mapping fields is not defined 1`] = ` +"Error extracting collector in src/fixtures/telemetry_collectors/unmapped_collector.ts +Error: usageCollector.schema must be defined." +`; diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts new file mode 100644 index 00000000000000..6083593431d9b3 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts @@ -0,0 +1,125 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as _ from 'lodash'; +import * as ts from 'typescript'; +import { parsedWorkingCollector } from './__fixture__/parsed_working_collector'; +import { checkCompatibleTypeDescriptor, checkMatchingMapping } from './check_collector_integrity'; +import * as path from 'path'; +import { readFile } from 'fs'; +import { promisify } from 'util'; +const read = promisify(readFile); + +async function parseJsonFile(relativePath: string) { + const schemaPath = path.resolve(__dirname, '__fixture__', relativePath); + const fileContent = await read(schemaPath, 'utf8'); + return JSON.parse(fileContent); +} + +describe('checkMatchingMapping', () => { + it('returns no diff on matching parsedCollections and stored mapping', async () => { + const mockSchema = await parseJsonFile('mock_schema.json'); + const diffs = checkMatchingMapping([parsedWorkingCollector], mockSchema); + expect(diffs).toEqual({}); + }); + + describe('Collector change', () => { + it('returns diff on mismatching parsedCollections and stored mapping', async () => { + const mockSchema = await parseJsonFile('mock_schema.json'); + const malformedParsedCollector = _.cloneDeep(parsedWorkingCollector); + const fieldMapping = { type: 'number' }; + malformedParsedCollector[1].schema.value.flat = fieldMapping; + + const diffs = checkMatchingMapping([malformedParsedCollector], mockSchema); + expect(diffs).toEqual({ + properties: { + my_working_collector: { + properties: { flat: fieldMapping }, + }, + }, + }); + }); + + it('returns diff on unknown parsedCollections', async () => { + const mockSchema = await parseJsonFile('mock_schema.json'); + const malformedParsedCollector = _.cloneDeep(parsedWorkingCollector); + const collectorName = 'New Collector in town!'; + const collectorMapping = { some_usage: { type: 'number' } }; + malformedParsedCollector[1].collectorName = collectorName; + malformedParsedCollector[1].schema.value = { some_usage: { type: 'number' } }; + + const diffs = checkMatchingMapping([malformedParsedCollector], mockSchema); + expect(diffs).toEqual({ + properties: { + [collectorName]: { + properties: collectorMapping, + }, + }, + }); + }); + }); +}); + +describe('checkCompatibleTypeDescriptor', () => { + it('returns no diff on compatible type descriptor with mapping', () => { + const incompatibles = checkCompatibleTypeDescriptor([parsedWorkingCollector]); + expect(incompatibles).toHaveLength(0); + }); + + describe('Interface Change', () => { + it('returns diff on incompatible type descriptor with mapping', () => { + const malformedParsedCollector = _.cloneDeep(parsedWorkingCollector); + malformedParsedCollector[1].fetch.typeDescriptor.flat.kind = ts.SyntaxKind.BooleanKeyword; + const incompatibles = checkCompatibleTypeDescriptor([malformedParsedCollector]); + expect(incompatibles).toHaveLength(1); + const { diff, message } = incompatibles[0]; + expect(diff).toEqual({ 'flat.kind': 'boolean' }); + expect(message).toHaveLength(1); + expect(message).toEqual([ + 'incompatible Type key (Usage.flat): expected ("string") got ("boolean").', + ]); + }); + + it.todo('returns diff when missing type descriptor'); + }); + + describe('Mapping change', () => { + it('returns no diff when mapping change between text and keyword', () => { + const malformedParsedCollector = _.cloneDeep(parsedWorkingCollector); + malformedParsedCollector[1].schema.value.flat.type = 'text'; + const incompatibles = checkCompatibleTypeDescriptor([malformedParsedCollector]); + expect(incompatibles).toHaveLength(0); + }); + + it('returns diff on incompatible type descriptor with mapping', () => { + const malformedParsedCollector = _.cloneDeep(parsedWorkingCollector); + malformedParsedCollector[1].schema.value.flat.type = 'boolean'; + const incompatibles = checkCompatibleTypeDescriptor([malformedParsedCollector]); + expect(incompatibles).toHaveLength(1); + const { diff, message } = incompatibles[0]; + expect(diff).toEqual({ 'flat.kind': 'string' }); + expect(message).toHaveLength(1); + expect(message).toEqual([ + 'incompatible Type key (Usage.flat): expected ("boolean") got ("string").', + ]); + }); + + it.todo('returns diff when missing mapping'); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector_integrity.ts b/packages/kbn-telemetry-tools/src/tools/check_collector_integrity.ts new file mode 100644 index 00000000000000..824132b05732ce --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/check_collector_integrity.ts @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as _ from 'lodash'; +import { difference, flattenKeys, pickDeep } from './utils'; +import { ParsedUsageCollection } from './ts_parser'; +import { generateMapping, compatibleSchemaTypes } from './manage_schema'; +import { kindToDescriptorName } from './serializer'; + +export function checkMatchingMapping( + UsageCollections: ParsedUsageCollection[], + esMapping: any +): any { + const generatedMapping = generateMapping(UsageCollections); + return difference(generatedMapping, esMapping); +} + +interface IncompatibleDescriptor { + diff: Record; + collectorPath: string; + message: string[]; +} +export function checkCompatibleTypeDescriptor( + usageCollections: ParsedUsageCollection[] +): IncompatibleDescriptor[] { + const results: Array = usageCollections.map( + ([collectorPath, collectorDetails]) => { + const typeDescriptorTypes = flattenKeys( + pickDeep(collectorDetails.fetch.typeDescriptor, 'kind') + ); + const typeDescriptorKinds = _.reduce( + typeDescriptorTypes, + (acc: any, type: number, key: string) => { + try { + acc[key] = kindToDescriptorName(type); + } catch (err) { + throw Error(`Unrecognized type (${key}: ${type}) in ${collectorPath}`); + } + return acc; + }, + {} as any + ); + + const schemaTypes = flattenKeys(pickDeep(collectorDetails.schema.value, 'type')); + const transformedMappingKinds = _.reduce( + schemaTypes, + (acc: any, type: string, key: string) => { + try { + acc[key.replace(/.type$/, '.kind')] = compatibleSchemaTypes(type as any); + } catch (err) { + throw Error(`Unrecognized type (${key}: ${type}) in ${collectorPath}`); + } + return acc; + }, + {} as any + ); + + const diff: any = difference(typeDescriptorKinds, transformedMappingKinds); + const diffEntries = Object.entries(diff); + + if (!diffEntries.length) { + return false; + } + + return { + diff, + collectorPath, + message: diffEntries.map(([key]) => { + const interfaceKey = key.replace('.kind', ''); + try { + const expectedDescriptorType = JSON.stringify(transformedMappingKinds[key], null, 2); + const actualDescriptorType = JSON.stringify(typeDescriptorKinds[key], null, 2); + return `incompatible Type key (${collectorDetails.fetch.typeName}.${interfaceKey}): expected (${expectedDescriptorType}) got (${actualDescriptorType}).`; + } catch (err) { + throw Error(`Error converting ${key} in ${collectorPath}.\n${err}`); + } + }), + }; + } + ); + + return results.filter((entry): entry is IncompatibleDescriptor => entry !== false); +} + +export function checkCollectorIntegrity(UsageCollections: ParsedUsageCollection[], esMapping: any) { + return UsageCollections; +} diff --git a/packages/kbn-telemetry-tools/src/tools/config.test.ts b/packages/kbn-telemetry-tools/src/tools/config.test.ts new file mode 100644 index 00000000000000..51ca0493cbb5a4 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/config.test.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from 'path'; +import { parseTelemetryRC } from './config'; + +describe('parseTelemetryRC', () => { + it('throw if config path is not absolute', async () => { + const fixtureDir = './__fixture__/'; + await expect(parseTelemetryRC(fixtureDir)).rejects.toThrowError(); + }); + + it('returns parsed rc file', async () => { + const configRoot = path.join(process.cwd(), 'src', 'fixtures', 'telemetry_collectors'); + const config = await parseTelemetryRC(configRoot); + expect(config).toStrictEqual([ + { + root: configRoot, + output: configRoot, + exclude: [path.resolve(configRoot, './unmapped_collector.ts')], + }, + ]); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/config.ts b/packages/kbn-telemetry-tools/src/tools/config.ts new file mode 100644 index 00000000000000..5724b869e8f5ea --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/config.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from 'path'; +import { readFileAsync } from './utils'; +import { TELEMETRY_RC } from './constants'; + +export interface TelemetryRC { + root: string; + output: string; + exclude: string[]; +} + +export async function readRcFile(rcRoot: string) { + if (!path.isAbsolute(rcRoot)) { + throw Error(`config root (${rcRoot}) must be an absolute path.`); + } + + const rcFile = path.resolve(rcRoot, TELEMETRY_RC); + const configString = await readFileAsync(rcFile, 'utf8'); + return JSON.parse(configString); +} + +export async function parseTelemetryRC(rcRoot: string): Promise { + const parsedRc = await readRcFile(rcRoot); + const configs = Array.isArray(parsedRc) ? parsedRc : [parsedRc]; + return configs.map(({ root, output, exclude = [] }) => { + if (typeof root !== 'string') { + throw Error('config.root must be a string.'); + } + if (typeof output !== 'string') { + throw Error('config.output must be a string.'); + } + if (!Array.isArray(exclude)) { + throw Error('config.exclude must be an array of strings.'); + } + + return { + root: path.join(rcRoot, root), + output: path.join(rcRoot, output), + exclude: exclude.map((excludedPath) => path.resolve(rcRoot, excludedPath)), + }; + }); +} diff --git a/packages/kbn-telemetry-tools/src/tools/constants.ts b/packages/kbn-telemetry-tools/src/tools/constants.ts new file mode 100644 index 00000000000000..8635b1a2e2528e --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/constants.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const TELEMETRY_RC = '.telemetryrc.json'; diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts new file mode 100644 index 00000000000000..1b4ed21a1635cf --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import * as path from 'path'; +import { extractCollectors, getProgramPaths } from './extract_collectors'; +import { parseTelemetryRC } from './config'; + +describe('extractCollectors', () => { + it('extracts collectors given rc file', async () => { + const configRoot = path.join(process.cwd(), 'src', 'fixtures', 'telemetry_collectors'); + const tsConfig = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json'); + if (!tsConfig) { + throw new Error('Could not find a valid tsconfig.json.'); + } + const configs = await parseTelemetryRC(configRoot); + expect(configs).toHaveLength(1); + const programPaths = await getProgramPaths(configs[0]); + + const results = [...extractCollectors(programPaths, tsConfig)]; + expect(results).toHaveLength(6); + expect(results).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.ts new file mode 100644 index 00000000000000..a638fde0214580 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import * as path from 'path'; +import { parseUsageCollection } from './ts_parser'; +import { globAsync } from './utils'; +import { TelemetryRC } from './config'; + +export async function getProgramPaths({ + root, + exclude, +}: Pick): Promise { + const filePaths = await globAsync('**/*.ts', { + cwd: root, + ignore: [ + '**/node_modules/**', + '**/*.test.*', + '**/*.mock.*', + '**/mocks.*', + '**/__fixture__/**', + '**/__tests__/**', + '**/public/**', + '**/dist/**', + '**/target/**', + '**/*.d.ts', + ], + }); + + if (filePaths.length === 0) { + throw Error(`No files found in ${root}`); + } + + const fullPaths = filePaths + .map((filePath) => path.join(root, filePath)) + .filter((fullPath) => !exclude.some((excludedPath) => fullPath.startsWith(excludedPath))); + + if (fullPaths.length === 0) { + throw Error(`No paths covered from ${root} by the .telemetryrc.json`); + } + + return fullPaths; +} + +export function* extractCollectors(fullPaths: string[], tsConfig: any) { + const program = ts.createProgram(fullPaths, tsConfig); + program.getTypeChecker(); + const sourceFiles = fullPaths.map((fullPath) => { + const sourceFile = program.getSourceFile(fullPath); + if (!sourceFile) { + throw Error(`Unable to get sourceFile ${fullPath}.`); + } + return sourceFile; + }); + + for (const sourceFile of sourceFiles) { + yield* parseUsageCollection(sourceFile, program); + } +} diff --git a/packages/kbn-telemetry-tools/src/tools/manage_schema.test.ts b/packages/kbn-telemetry-tools/src/tools/manage_schema.test.ts new file mode 100644 index 00000000000000..8f4bfc66b32aeb --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/manage_schema.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { generateMapping } from './manage_schema'; +import { parsedWorkingCollector } from './__fixture__/parsed_working_collector'; +import * as path from 'path'; +import { readFile } from 'fs'; +import { promisify } from 'util'; +const read = promisify(readFile); + +async function parseJsonFile(relativePath: string) { + const schemaPath = path.resolve(__dirname, '__fixture__', relativePath); + const fileContent = await read(schemaPath, 'utf8'); + return JSON.parse(fileContent); +} + +describe('generateMapping', () => { + it('generates a mapping file', async () => { + const mockSchema = await parseJsonFile('mock_schema.json'); + const result = generateMapping([parsedWorkingCollector]); + expect(result).toEqual(mockSchema); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/manage_schema.ts b/packages/kbn-telemetry-tools/src/tools/manage_schema.ts new file mode 100644 index 00000000000000..d422837140d802 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/manage_schema.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ParsedUsageCollection } from './ts_parser'; + +export type AllowedSchemaTypes = + | 'keyword' + | 'text' + | 'number' + | 'boolean' + | 'long' + | 'date' + | 'float'; + +export function compatibleSchemaTypes(type: AllowedSchemaTypes) { + switch (type) { + case 'keyword': + case 'text': + case 'date': + return 'string'; + case 'boolean': + return 'boolean'; + case 'number': + case 'float': + case 'long': + return 'number'; + default: + throw new Error(`Unknown schema type ${type}`); + } +} + +export function isObjectMapping(entity: any) { + if (typeof entity === 'object') { + // 'type' is explicitly specified to be an object. + if (typeof entity.type === 'string' && entity.type === 'object') { + return true; + } + + // 'type' is not set; ES defaults to object mapping for when type is unspecified. + if (typeof entity.type === 'undefined') { + return true; + } + + // 'type' is a field in the mapping and is not the type of the mapping. + if (typeof entity.type === 'object') { + return true; + } + } + + return false; +} + +function transformToEsMapping(usageMappingValue: any) { + const fieldMapping: any = { properties: {} }; + for (const [key, value] of Object.entries(usageMappingValue)) { + fieldMapping.properties[key] = isObjectMapping(value) ? transformToEsMapping(value) : value; + } + return fieldMapping; +} + +export function generateMapping(usageCollections: ParsedUsageCollection[]) { + const esMapping: any = { properties: {} }; + for (const [, collecionDetails] of usageCollections) { + esMapping.properties[collecionDetails.collectorName] = transformToEsMapping( + collecionDetails.schema.value + ); + } + + return esMapping; +} diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.test.ts b/packages/kbn-telemetry-tools/src/tools/serializer.test.ts new file mode 100644 index 00000000000000..9475574a442192 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/serializer.test.ts @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import * as path from 'path'; +import { getDescriptor, TelemetryKinds } from './serializer'; +import { traverseNodes } from './ts_parser'; + +export function loadFixtureProgram(fixtureName: string) { + const fixturePath = path.resolve( + process.cwd(), + 'src', + 'fixtures', + 'telemetry_collectors', + `${fixtureName}.ts` + ); + const tsConfig = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json'); + if (!tsConfig) { + throw new Error('Could not find a valid tsconfig.json.'); + } + const program = ts.createProgram([fixturePath], tsConfig as any); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(fixturePath); + if (!sourceFile) { + throw Error('sourceFile is undefined!'); + } + return { program, checker, sourceFile }; +} + +describe('getDescriptor', () => { + const usageInterfaces = new Map(); + let tsProgram: ts.Program; + beforeAll(() => { + const { program, sourceFile } = loadFixtureProgram('constants'); + tsProgram = program; + for (const node of traverseNodes(sourceFile)) { + if (ts.isInterfaceDeclaration(node)) { + const interfaceName = node.name.getText(); + usageInterfaces.set(interfaceName, node); + } + } + }); + + it('serializes flat types', () => { + const usageInterface = usageInterfaces.get('Usage'); + const descriptor = getDescriptor(usageInterface!, tsProgram); + expect(descriptor).toEqual({ + locale: { kind: ts.SyntaxKind.StringKeyword, type: 'StringKeyword' }, + }); + }); + + it('serializes union types', () => { + const usageInterface = usageInterfaces.get('WithUnion'); + const descriptor = getDescriptor(usageInterface!, tsProgram); + + expect(descriptor).toEqual({ + prop1: { kind: ts.SyntaxKind.StringKeyword, type: 'StringKeyword' }, + prop2: { kind: ts.SyntaxKind.StringKeyword, type: 'StringKeyword' }, + prop3: { kind: ts.SyntaxKind.StringKeyword, type: 'StringKeyword' }, + prop4: { kind: ts.SyntaxKind.StringLiteral, type: 'StringLiteral' }, + prop5: { kind: ts.SyntaxKind.FirstLiteralToken, type: 'FirstLiteralToken' }, + }); + }); + + it('serializes Moment Dates', () => { + const usageInterface = usageInterfaces.get('WithMoment'); + const descriptor = getDescriptor(usageInterface!, tsProgram); + expect(descriptor).toEqual({ + prop1: { kind: TelemetryKinds.MomentDate, type: 'MomentDate' }, + prop2: { kind: TelemetryKinds.MomentDate, type: 'MomentDate' }, + prop3: { kind: TelemetryKinds.MomentDate, type: 'MomentDate' }, + prop4: { kind: TelemetryKinds.Date, type: 'Date' }, + }); + }); + + it('throws error on conflicting union types', () => { + const usageInterface = usageInterfaces.get('WithConflictingUnion'); + expect(() => getDescriptor(usageInterface!, tsProgram)).toThrowError( + 'Mapping does not support conflicting union types.' + ); + }); + + it('throws error on unsupported union types', () => { + const usageInterface = usageInterfaces.get('WithUnsupportedUnion'); + expect(() => getDescriptor(usageInterface!, tsProgram)).toThrowError( + 'Mapping does not support conflicting union types.' + ); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.ts b/packages/kbn-telemetry-tools/src/tools/serializer.ts new file mode 100644 index 00000000000000..bce5dd7f58643b --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/serializer.ts @@ -0,0 +1,169 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import { uniq } from 'lodash'; +import { + getResolvedModuleSourceFile, + getIdentifierDeclarationFromSource, + getModuleSpecifier, +} from './utils'; + +export enum TelemetryKinds { + MomentDate = 1000, + Date = 10001, +} + +interface DescriptorValue { + kind: ts.SyntaxKind | TelemetryKinds; + type: keyof typeof ts.SyntaxKind | keyof typeof TelemetryKinds; +} + +export interface Descriptor { + [name: string]: Descriptor | DescriptorValue; +} + +export function isObjectDescriptor(value: any) { + if (typeof value === 'object') { + if (typeof value.type === 'string' && value.type === 'object') { + return true; + } + + if (typeof value.type === 'undefined') { + return true; + } + } + + return false; +} + +export function kindToDescriptorName(kind: number) { + switch (kind) { + case ts.SyntaxKind.StringKeyword: + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.SetKeyword: + case TelemetryKinds.Date: + case TelemetryKinds.MomentDate: + return 'string'; + case ts.SyntaxKind.BooleanKeyword: + return 'boolean'; + case ts.SyntaxKind.NumberKeyword: + case ts.SyntaxKind.NumericLiteral: + return 'number'; + default: + throw new Error(`Unknown kind ${kind}`); + } +} + +export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor | DescriptorValue { + if (ts.isMethodSignature(node) || ts.isPropertySignature(node)) { + if (node.type) { + return getDescriptor(node.type, program); + } + } + if (ts.isTypeLiteralNode(node) || ts.isInterfaceDeclaration(node)) { + return node.members.reduce((acc, m) => { + acc[m.name?.getText() || ''] = getDescriptor(m, program); + return acc; + }, {} as any); + } + + if (ts.SyntaxKind.FirstNode === node.kind) { + return getDescriptor((node as any).right, program); + } + + if (ts.isIdentifier(node)) { + const identifierName = node.getText(); + if (identifierName === 'Date') { + return { kind: TelemetryKinds.Date, type: 'Date' }; + } + if (identifierName === 'Moment') { + return { kind: TelemetryKinds.MomentDate, type: 'MomentDate' }; + } + throw new Error(`Unsupported Identifier ${identifierName}.`); + } + + if (ts.isTypeReferenceNode(node)) { + const typeChecker = program.getTypeChecker(); + const symbol = typeChecker.getSymbolAtLocation(node.typeName); + const symbolName = symbol?.getName(); + if (symbolName === 'Moment') { + return { kind: TelemetryKinds.MomentDate, type: 'MomentDate' }; + } + if (symbolName === 'Date') { + return { kind: TelemetryKinds.Date, type: 'Date' }; + } + const declaration = (symbol?.getDeclarations() || [])[0]; + if (declaration) { + return getDescriptor(declaration, program); + } + return getDescriptor(node.typeName, program); + } + + if (ts.isImportSpecifier(node)) { + const source = node.getSourceFile(); + const importedModuleName = getModuleSpecifier(node); + + const declarationSource = getResolvedModuleSourceFile(source, program, importedModuleName); + const declarationNode = getIdentifierDeclarationFromSource(node.name, declarationSource); + return getDescriptor(declarationNode, program); + } + + if (ts.isArrayTypeNode(node)) { + return getDescriptor(node.elementType, program); + } + + if (ts.isLiteralTypeNode(node)) { + return { + kind: node.literal.kind, + type: ts.SyntaxKind[node.literal.kind] as keyof typeof ts.SyntaxKind, + }; + } + + if (ts.isUnionTypeNode(node)) { + const types = node.types.filter((typeNode) => { + return ( + typeNode.kind !== ts.SyntaxKind.NullKeyword && + typeNode.kind !== ts.SyntaxKind.UndefinedKeyword + ); + }); + + const kinds = types.map((typeNode) => getDescriptor(typeNode, program)); + + const uniqueKinds = uniq(kinds, 'kind'); + + if (uniqueKinds.length !== 1) { + throw Error('Mapping does not support conflicting union types.'); + } + + return uniqueKinds[0]; + } + + switch (node.kind) { + case ts.SyntaxKind.NumberKeyword: + case ts.SyntaxKind.BooleanKeyword: + case ts.SyntaxKind.StringKeyword: + case ts.SyntaxKind.SetKeyword: + return { kind: node.kind, type: ts.SyntaxKind[node.kind] as keyof typeof ts.SyntaxKind }; + case ts.SyntaxKind.UnionType: + case ts.SyntaxKind.AnyKeyword: + default: + throw new Error(`Unknown type ${ts.SyntaxKind[node.kind]}; ${node.getText()}`); + } +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/check_compatible_types_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/check_compatible_types_task.ts new file mode 100644 index 00000000000000..dae4d0f1ad168a --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/check_compatible_types_task.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TaskContext } from './task_context'; +import { checkCompatibleTypeDescriptor } from '../check_collector_integrity'; + +export function checkCompatibleTypesTask({ reporter, roots }: TaskContext) { + return roots.map((root) => ({ + task: async () => { + if (root.parsedCollections) { + const differences = checkCompatibleTypeDescriptor(root.parsedCollections); + const reporterWithContext = reporter.withContext({ name: root.config.root }); + if (differences.length) { + reporterWithContext.report( + `${JSON.stringify( + differences, + null, + 2 + )}. \nPlease fix the collectors and run the check again.` + ); + throw reporter; + } + } + }, + title: `Checking in ${root.config.root}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts new file mode 100644 index 00000000000000..a1f23bcd44c765 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from 'path'; +import { TaskContext } from './task_context'; +import { checkMatchingMapping } from '../check_collector_integrity'; +import { readFileAsync } from '../utils'; + +export function checkMatchingSchemasTask({ roots }: TaskContext) { + return roots.map((root) => ({ + task: async () => { + const fullPath = path.resolve(process.cwd(), root.config.output); + const esMappingString = await readFileAsync(fullPath, 'utf-8'); + const esMapping = JSON.parse(esMappingString); + + if (root.parsedCollections) { + const differences = checkMatchingMapping(root.parsedCollections, esMapping); + + root.esMappingDiffs = Object.keys(differences); + } + }, + title: `Checking in ${root.config.root}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/error_reporter.ts b/packages/kbn-telemetry-tools/src/tools/tasks/error_reporter.ts new file mode 100644 index 00000000000000..246d659667281e --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/error_reporter.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; +import { normalizePath } from '../utils'; + +export class ErrorReporter { + errors: string[] = []; + + withContext(context: any) { + return { report: (error: any) => this.report(error, context) }; + } + report(error: any, context: any) { + this.errors.push( + `${chalk.white.bgRed(' TELEMETRY ERROR ')} Error in ${normalizePath(context.name)}\n${error}` + ); + } +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/extract_collectors_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/extract_collectors_task.ts new file mode 100644 index 00000000000000..834ec71e220320 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/extract_collectors_task.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import * as path from 'path'; +import { TaskContext } from './task_context'; +import { extractCollectors, getProgramPaths } from '../extract_collectors'; + +export function extractCollectorsTask( + { roots }: TaskContext, + restrictProgramToPath?: string | string[] +) { + return roots.map((root) => ({ + task: async () => { + const tsConfig = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json'); + if (!tsConfig) { + throw new Error('Could not find a valid tsconfig.json.'); + } + const programPaths = await getProgramPaths(root.config); + + if (typeof restrictProgramToPath !== 'undefined') { + const restrictProgramToPaths = Array.isArray(restrictProgramToPath) + ? restrictProgramToPath + : [restrictProgramToPath]; + + const fullRestrictedPaths = restrictProgramToPaths.map((collectorPath) => + path.resolve(process.cwd(), collectorPath) + ); + const restrictedProgramPaths = programPaths.filter((programPath) => + fullRestrictedPaths.includes(programPath) + ); + if (restrictedProgramPaths.length) { + root.parsedCollections = [...extractCollectors(restrictedProgramPaths, tsConfig)]; + } + return; + } + + root.parsedCollections = [...extractCollectors(programPaths, tsConfig)]; + }, + title: `Extracting collectors in ${root.config.root}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/generate_schemas_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/generate_schemas_task.ts new file mode 100644 index 00000000000000..f6d15c7127d4eb --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/generate_schemas_task.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as _ from 'lodash'; +import { TaskContext } from './task_context'; +import { generateMapping } from '../manage_schema'; + +export function generateSchemasTask({ roots }: TaskContext) { + return roots.map((root) => ({ + task: () => { + if (!root.parsedCollections || !root.parsedCollections.length) { + return; + } + const mapping = generateMapping(root.parsedCollections); + root.mapping = mapping; + }, + title: `Generating mapping for ${root.config.root}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/index.ts b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts new file mode 100644 index 00000000000000..cbe74aeb483e41 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/index.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ErrorReporter } from './error_reporter'; +export { TaskContext, createTaskContext } from './task_context'; + +export { parseConfigsTask } from './parse_configs_task'; +export { extractCollectorsTask } from './extract_collectors_task'; +export { generateSchemasTask } from './generate_schemas_task'; +export { writeToFileTask } from './write_to_file_task'; +export { checkMatchingSchemasTask } from './check_matching_schemas_task'; +export { checkCompatibleTypesTask } from './check_compatible_types_task'; diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/parse_configs_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/parse_configs_task.ts new file mode 100644 index 00000000000000..00b319006e2ee3 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/parse_configs_task.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from 'path'; +import { parseTelemetryRC } from '../config'; +import { TaskContext } from './task_context'; + +export function parseConfigsTask() { + const kibanaRoot = process.cwd(); + const xpackRoot = path.join(kibanaRoot, 'x-pack'); + + const configRoots = [kibanaRoot, xpackRoot]; + + return configRoots.map((configRoot) => ({ + task: async (context: TaskContext) => { + try { + const configs = await parseTelemetryRC(configRoot); + configs.forEach((config) => { + context.roots.push({ config }); + }); + } catch (err) { + const { reporter } = context; + const reporterWithContext = reporter.withContext({ name: configRoot }); + reporterWithContext.report(err); + throw reporter; + } + }, + title: `Parsing configs in ${configRoot}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/task_context.ts b/packages/kbn-telemetry-tools/src/tools/tasks/task_context.ts new file mode 100644 index 00000000000000..78d0b7fbd6c2d7 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/task_context.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TelemetryRC } from '../config'; +import { ErrorReporter } from './error_reporter'; +import { ParsedUsageCollection } from '../ts_parser'; +export interface TelemetryRoot { + config: TelemetryRC; + parsedCollections?: ParsedUsageCollection[]; + mapping?: any; + esMappingDiffs?: string[]; +} + +export interface TaskContext { + reporter: ErrorReporter; + roots: TelemetryRoot[]; +} + +export function createTaskContext(): TaskContext { + const reporter = new ErrorReporter(); + return { + roots: [], + reporter, + }; +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/write_to_file_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/write_to_file_task.ts new file mode 100644 index 00000000000000..fcfc09db65426f --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/tasks/write_to_file_task.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as path from 'path'; +import { writeFileAsync } from '../utils'; +import { TaskContext } from './task_context'; + +export function writeToFileTask({ roots }: TaskContext) { + return roots.map((root) => ({ + task: async () => { + const fullPath = path.resolve(process.cwd(), root.config.output); + if (root.mapping && Object.keys(root.mapping.properties).length > 0) { + const serializedMapping = JSON.stringify(root.mapping, null, 2).concat('\n'); + await writeFileAsync(fullPath, serializedMapping); + } + }, + title: `Writing mapping for ${root.config.root}`, + })); +} diff --git a/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts b/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts new file mode 100644 index 00000000000000..b7ca33a7bcd743 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/ts_parser.test.ts @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parseUsageCollection } from './ts_parser'; +import * as ts from 'typescript'; +import * as path from 'path'; +import { parsedWorkingCollector } from './__fixture__/parsed_working_collector'; +import { parsedNestedCollector } from './__fixture__/parsed_nested_collector'; +import { parsedExternallyDefinedCollector } from './__fixture__/parsed_externally_defined_collector'; +import { parsedImportedUsageInterface } from './__fixture__/parsed_imported_usage_interface'; +import { parsedImportedSchemaCollector } from './__fixture__/parsed_imported_schema'; + +export function loadFixtureProgram(fixtureName: string) { + const fixturePath = path.resolve( + process.cwd(), + 'src', + 'fixtures', + 'telemetry_collectors', + `${fixtureName}.ts` + ); + const tsConfig = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json'); + if (!tsConfig) { + throw new Error('Could not find a valid tsconfig.json.'); + } + const program = ts.createProgram([fixturePath], tsConfig as any); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(fixturePath); + if (!sourceFile) { + throw Error('sourceFile is undefined!'); + } + return { program, checker, sourceFile }; +} + +describe('parseUsageCollection', () => { + it.todo('throws when a function is returned from fetch'); + it.todo('throws when an object is not returned from fetch'); + + it('throws when mapping fields is not defined', () => { + const { program, sourceFile } = loadFixtureProgram('unmapped_collector'); + expect(() => [...parseUsageCollection(sourceFile, program)]).toThrowErrorMatchingSnapshot(); + }); + + it('parses root level defined collector', () => { + const { program, sourceFile } = loadFixtureProgram('working_collector'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual([parsedWorkingCollector]); + }); + + it('parses nested collectors', () => { + const { program, sourceFile } = loadFixtureProgram('nested_collector'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual([parsedNestedCollector]); + }); + + it('parses imported schema property', () => { + const { program, sourceFile } = loadFixtureProgram('imported_schema'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual(parsedImportedSchemaCollector); + }); + + it('parses externally defined collectors', () => { + const { program, sourceFile } = loadFixtureProgram('externally_defined_collector'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual(parsedExternallyDefinedCollector); + }); + + it('parses imported Usage interface', () => { + const { program, sourceFile } = loadFixtureProgram('imported_usage_interface'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual(parsedImportedUsageInterface); + }); + + it('skips files that do not define a collector', () => { + const { program, sourceFile } = loadFixtureProgram('file_with_no_collector'); + const result = [...parseUsageCollection(sourceFile, program)]; + expect(result).toEqual([]); + }); +}); diff --git a/packages/kbn-telemetry-tools/src/tools/ts_parser.ts b/packages/kbn-telemetry-tools/src/tools/ts_parser.ts new file mode 100644 index 00000000000000..6af8450f5a2e8c --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/ts_parser.ts @@ -0,0 +1,210 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import { createFailError } from '@kbn/dev-utils'; +import * as path from 'path'; +import { getProperty, getPropertyValue } from './utils'; +import { getDescriptor, Descriptor } from './serializer'; + +export function* traverseNodes(maybeNodes: ts.Node | ts.Node[]): Generator { + const nodes: ts.Node[] = Array.isArray(maybeNodes) ? maybeNodes : [maybeNodes]; + + for (const node of nodes) { + const children: ts.Node[] = []; + yield node; + ts.forEachChild(node, (child) => { + children.push(child); + }); + for (const child of children) { + yield* traverseNodes(child); + } + } +} + +export function isMakeUsageCollectorFunction( + node: ts.Node, + sourceFile: ts.SourceFile +): node is ts.CallExpression { + if (ts.isCallExpression(node)) { + const isMakeUsageCollector = /makeUsageCollector$/.test(node.expression.getText(sourceFile)); + if (isMakeUsageCollector) { + return true; + } + } + + return false; +} + +export interface CollectorDetails { + collectorName: string; + fetch: { typeName: string; typeDescriptor: Descriptor }; + schema: { value: any }; +} + +function getCollectionConfigNode( + collectorNode: ts.CallExpression, + sourceFile: ts.SourceFile +): ts.Expression { + if (collectorNode.arguments.length > 1) { + throw Error(`makeUsageCollector does not accept more than one argument.`); + } + const collectorConfig = collectorNode.arguments[0]; + + if (ts.isObjectLiteralExpression(collectorConfig)) { + return collectorConfig; + } + + const variableDefintionName = collectorConfig.getText(); + for (const node of traverseNodes(sourceFile)) { + if (ts.isVariableDeclaration(node)) { + const declarationName = node.name.getText(); + if (declarationName === variableDefintionName) { + if (!node.initializer) { + throw Error(`Unable to parse collector configs.`); + } + if (ts.isObjectLiteralExpression(node.initializer)) { + return node.initializer; + } + if (ts.isCallExpression(node.initializer)) { + const functionName = node.initializer.expression.getText(sourceFile); + for (const sfNode of traverseNodes(sourceFile)) { + if (ts.isFunctionDeclaration(sfNode)) { + const fnDeclarationName = sfNode.name?.getText(); + if (fnDeclarationName === functionName) { + const returnStatements: ts.ReturnStatement[] = []; + for (const fnNode of traverseNodes(sfNode)) { + if (ts.isReturnStatement(fnNode) && fnNode.parent === sfNode.body) { + returnStatements.push(fnNode); + } + } + + if (returnStatements.length > 1) { + throw Error(`Collector function cannot have multiple return statements.`); + } + if (returnStatements.length === 0) { + throw Error(`Collector function must have a return statement.`); + } + if (!returnStatements[0].expression) { + throw Error(`Collector function return statement must be an expression.`); + } + + return returnStatements[0].expression; + } + } + } + } + } + } + } + + throw Error(`makeUsageCollector argument must be an object.`); +} + +function extractCollectorDetails( + collectorNode: ts.CallExpression, + program: ts.Program, + sourceFile: ts.SourceFile +): CollectorDetails { + if (collectorNode.arguments.length > 1) { + throw Error(`makeUsageCollector does not accept more than one argument.`); + } + + const collectorConfig = getCollectionConfigNode(collectorNode, sourceFile); + + const typeProperty = getProperty(collectorConfig, 'type'); + if (!typeProperty) { + throw Error(`usageCollector.type must be defined.`); + } + const typePropertyValue = getPropertyValue(typeProperty, program); + if (!typePropertyValue || typeof typePropertyValue !== 'string') { + throw Error(`usageCollector.type must be be a non-empty string literal.`); + } + + const fetchProperty = getProperty(collectorConfig, 'fetch'); + if (!fetchProperty) { + throw Error(`usageCollector.fetch must be defined.`); + } + const schemaProperty = getProperty(collectorConfig, 'schema'); + if (!schemaProperty) { + throw Error(`usageCollector.schema must be defined.`); + } + + const schemaPropertyValue = getPropertyValue(schemaProperty, program, { chaseImport: true }); + if (!schemaPropertyValue || typeof schemaPropertyValue !== 'object') { + throw Error(`usageCollector.schema must be be an object.`); + } + + const collectorNodeType = collectorNode.typeArguments; + if (!collectorNodeType || collectorNodeType?.length === 0) { + throw Error(`makeUsageCollector requires a Usage type makeUsageCollector({ ... }).`); + } + + const usageTypeNode = collectorNodeType[0]; + const usageTypeName = usageTypeNode.getText(); + const usageType = getDescriptor(usageTypeNode, program) as Descriptor; + + return { + collectorName: typePropertyValue, + schema: { + value: schemaPropertyValue, + }, + fetch: { + typeName: usageTypeName, + typeDescriptor: usageType, + }, + }; +} + +export function sourceHasUsageCollector(sourceFile: ts.SourceFile) { + if (sourceFile.isDeclarationFile === true || (sourceFile as any).identifierCount === 0) { + return false; + } + + const identifiers = (sourceFile as any).identifiers; + if ( + (!identifiers.get('makeUsageCollector') && !identifiers.get('type')) || + !identifiers.get('fetch') + ) { + return false; + } + + return true; +} + +export type ParsedUsageCollection = [string, CollectorDetails]; + +export function* parseUsageCollection( + sourceFile: ts.SourceFile, + program: ts.Program +): Generator { + const relativePath = path.relative(process.cwd(), sourceFile.fileName); + if (sourceHasUsageCollector(sourceFile)) { + for (const node of traverseNodes(sourceFile)) { + if (isMakeUsageCollectorFunction(node, sourceFile)) { + try { + const collectorDetails = extractCollectorDetails(node, program, sourceFile); + yield [relativePath, collectorDetails]; + } catch (err) { + throw createFailError(`Error extracting collector in ${relativePath}\n${err}`); + } + } + } + } +} diff --git a/packages/kbn-telemetry-tools/src/tools/utils.ts b/packages/kbn-telemetry-tools/src/tools/utils.ts new file mode 100644 index 00000000000000..f5cf74ae35e456 --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/utils.ts @@ -0,0 +1,238 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as ts from 'typescript'; +import * as _ from 'lodash'; +import * as path from 'path'; +import glob from 'glob'; +import { readFile, writeFile } from 'fs'; +import { promisify } from 'util'; +import normalize from 'normalize-path'; +import { Optional } from '@kbn/utility-types'; + +export const readFileAsync = promisify(readFile); +export const writeFileAsync = promisify(writeFile); +export const globAsync = promisify(glob); + +export function isPropertyWithKey(property: ts.Node, identifierName: string) { + if (ts.isPropertyAssignment(property) || ts.isMethodDeclaration(property)) { + if (ts.isIdentifier(property.name)) { + return property.name.text === identifierName; + } + } + + return false; +} + +export function getProperty(objectNode: any, propertyName: string): ts.Node | null { + let foundProperty = null; + ts.visitNodes(objectNode?.properties || [], (node) => { + if (isPropertyWithKey(node, propertyName)) { + foundProperty = node; + return node; + } + }); + + return foundProperty; +} + +export function getModuleSpecifier(node: ts.Node): string { + if ((node as any).moduleSpecifier) { + return (node as any).moduleSpecifier.text; + } + return getModuleSpecifier(node.parent); +} + +export function getIdentifierDeclarationFromSource(node: ts.Node, source: ts.SourceFile) { + if (!ts.isIdentifier(node)) { + throw new Error(`node is not an identifier ${node.getText()}`); + } + + const identifierName = node.getText(); + const identifierDefinition: ts.Node = (source as any).locals.get(identifierName); + if (!identifierDefinition) { + throw new Error(`Unable to fine identifier in source ${identifierName}`); + } + const declarations = (identifierDefinition as any).declarations as ts.Node[]; + + const latestDeclaration: ts.Node | false | undefined = + Array.isArray(declarations) && declarations[declarations.length - 1]; + if (!latestDeclaration) { + throw new Error(`Unable to fine declaration for identifier ${identifierName}`); + } + + return latestDeclaration; +} + +export function getIdentifierDeclaration(node: ts.Node) { + const source = node.getSourceFile(); + if (!source) { + throw new Error('Unable to get source from node; check program configs.'); + } + + return getIdentifierDeclarationFromSource(node, source); +} + +export function getVariableValue(node: ts.Node): string | Record { + if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } + + if (ts.isObjectLiteralExpression(node)) { + return serializeObject(node); + } + + throw Error(`Unsuppored Node: cannot get value of node (${node.getText()}) of kind ${node.kind}`); +} + +export function serializeObject(node: ts.Node) { + if (!ts.isObjectLiteralExpression(node)) { + throw new Error(`Expecting Object literal Expression got ${node.getText()}`); + } + + const value: Record = {}; + for (const property of node.properties) { + const propertyName = property.name?.getText(); + if (typeof propertyName === 'undefined') { + throw new Error(`Unable to get property name ${property.getText()}`); + } + if (ts.isPropertyAssignment(property)) { + value[propertyName] = getVariableValue(property.initializer); + } else { + value[propertyName] = getVariableValue(property); + } + } + + return value; +} + +export function getResolvedModuleSourceFile( + originalSource: ts.SourceFile, + program: ts.Program, + importedModuleName: string +) { + const resolvedModule = (originalSource as any).resolvedModules.get(importedModuleName); + const resolvedModuleSourceFile = program.getSourceFile(resolvedModule.resolvedFileName); + if (!resolvedModuleSourceFile) { + throw new Error(`Unable to find resolved module ${importedModuleName}`); + } + return resolvedModuleSourceFile; +} + +export function getPropertyValue( + node: ts.Node, + program: ts.Program, + config: Optional<{ chaseImport: boolean }> = {} +) { + const { chaseImport = false } = config; + + if (ts.isPropertyAssignment(node)) { + const { initializer } = node; + + if (ts.isIdentifier(initializer)) { + const identifierName = initializer.getText(); + const declaration = getIdentifierDeclaration(initializer); + if (ts.isImportSpecifier(declaration)) { + if (!chaseImport) { + throw new Error( + `Value of node ${identifierName} is imported from another file. Chasing imports is not allowed.` + ); + } + + const importedModuleName = getModuleSpecifier(declaration); + + const source = node.getSourceFile(); + const declarationSource = getResolvedModuleSourceFile(source, program, importedModuleName); + const declarationNode = getIdentifierDeclarationFromSource(initializer, declarationSource); + if (!ts.isVariableDeclaration(declarationNode)) { + throw new Error(`Expected ${identifierName} to be variable declaration.`); + } + if (!declarationNode.initializer) { + throw new Error(`Expected ${identifierName} to be initialized.`); + } + const serializedObject = serializeObject(declarationNode.initializer); + return serializedObject; + } + + return getVariableValue(declaration); + } + + return getVariableValue(initializer); + } +} + +export function pickDeep(collection: any, identity: any, thisArg?: any) { + const picked: any = _.pick(collection, identity, thisArg); + const collections = _.pick(collection, _.isObject, thisArg); + + _.each(collections, function (item, key) { + let object; + if (_.isArray(item)) { + object = _.reduce( + item, + function (result, value) { + const pickedDeep = pickDeep(value, identity, thisArg); + if (!_.isEmpty(pickedDeep)) { + result.push(pickedDeep); + } + return result; + }, + [] as any[] + ); + } else { + object = pickDeep(item, identity, thisArg); + } + + if (!_.isEmpty(object)) { + picked[key || ''] = object; + } + }); + + return picked; +} + +export const flattenKeys = (obj: any, keyPath: any[] = []): any => { + if (_.isObject(obj)) { + return _.reduce( + obj, + (cum, next, key) => { + const keys = [...keyPath, key]; + return _.merge(cum, flattenKeys(next, keys)); + }, + {} + ); + } + return { [keyPath.join('.')]: obj }; +}; + +export function difference(actual: any, expected: any) { + function changes(obj: any, base: any) { + return _.transform(obj, function (result, value, key) { + if (key && !_.isEqual(value, base[key])) { + result[key] = + _.isObject(value) && _.isObject(base[key]) ? changes(value, base[key]) : value; + } + }); + } + return changes(actual, expected); +} + +export function normalizePath(inputPath: string) { + return normalize(path.relative('.', inputPath)); +} diff --git a/packages/kbn-telemetry-tools/tsconfig.json b/packages/kbn-telemetry-tools/tsconfig.json new file mode 100644 index 00000000000000..13ce8ef2bad60b --- /dev/null +++ b/packages/kbn-telemetry-tools/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + ] +} diff --git a/scripts/telemetry_check.js b/scripts/telemetry_check.js new file mode 100644 index 00000000000000..06b3ed46bdba6a --- /dev/null +++ b/scripts/telemetry_check.js @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +require('../src/setup_node_env/prebuilt_dev_only_entry'); +require('@kbn/telemetry-tools').runTelemetryCheck(); diff --git a/scripts/telemetry_extract.js b/scripts/telemetry_extract.js new file mode 100644 index 00000000000000..051bee26537b9b --- /dev/null +++ b/scripts/telemetry_extract.js @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +require('../src/setup_node_env/prebuilt_dev_only_entry'); +require('@kbn/telemetry-tools').runTelemetryExtract(); diff --git a/src/fixtures/telemetry_collectors/.telemetryrc.json b/src/fixtures/telemetry_collectors/.telemetryrc.json new file mode 100644 index 00000000000000..31203149c9b579 --- /dev/null +++ b/src/fixtures/telemetry_collectors/.telemetryrc.json @@ -0,0 +1,7 @@ +{ + "root": ".", + "output": ".", + "exclude": [ + "./unmapped_collector.ts" + ] +} diff --git a/src/fixtures/telemetry_collectors/constants.ts b/src/fixtures/telemetry_collectors/constants.ts new file mode 100644 index 00000000000000..4aac9e66cdbdb3 --- /dev/null +++ b/src/fixtures/telemetry_collectors/constants.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import moment, { Moment } from 'moment'; +import { MakeSchemaFrom } from '../../plugins/usage_collection/server'; + +export interface Usage { + locale: string; +} + +export interface WithUnion { + prop1: string | null; + prop2: string | null | undefined; + prop3?: string | null; + prop4: 'opt1' | 'opt2'; + prop5: 123 | 431; +} + +export interface WithMoment { + prop1: Moment; + prop2: moment.Moment; + prop3: Moment[]; + prop4: Date[]; +} + +export interface WithConflictingUnion { + prop1: 123 | 'str'; +} + +export interface WithUnsupportedUnion { + prop1: 123 | Moment; +} + +export const externallyDefinedSchema: MakeSchemaFrom<{ locale: string }> = { + locale: { + type: 'keyword', + }, +}; diff --git a/src/fixtures/telemetry_collectors/externally_defined_collector.ts b/src/fixtures/telemetry_collectors/externally_defined_collector.ts new file mode 100644 index 00000000000000..00a8d643e27b33 --- /dev/null +++ b/src/fixtures/telemetry_collectors/externally_defined_collector.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet, CollectorOptions } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const collectorSet = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + locale: string; +} + +function createCollector(): CollectorOptions { + return { + type: 'from_fn_collector', + isReady: () => true, + fetch(): Usage { + return { + locale: 'en', + }; + }, + schema: { + locale: { + type: 'keyword', + }, + }, + }; +} + +export function defineCollectorFromVariable() { + const fromVarCollector: CollectorOptions = { + type: 'from_variable_collector', + isReady: () => true, + fetch(): Usage { + return { + locale: 'en', + }; + }, + schema: { + locale: { + type: 'keyword', + }, + }, + }; + + collectorSet.makeUsageCollector(fromVarCollector); +} + +export function defineCollectorFromFn() { + const fromFnCollector = createCollector(); + + collectorSet.makeUsageCollector(fromFnCollector); +} diff --git a/src/fixtures/telemetry_collectors/file_with_no_collector.ts b/src/fixtures/telemetry_collectors/file_with_no_collector.ts new file mode 100644 index 00000000000000..2e1870e486269d --- /dev/null +++ b/src/fixtures/telemetry_collectors/file_with_no_collector.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const SOME_CONST: number = 123; diff --git a/src/fixtures/telemetry_collectors/imported_schema.ts b/src/fixtures/telemetry_collectors/imported_schema.ts new file mode 100644 index 00000000000000..66d04700642d17 --- /dev/null +++ b/src/fixtures/telemetry_collectors/imported_schema.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; +import { externallyDefinedSchema } from './constants'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + locale?: string; +} + +export const myCollector = makeUsageCollector({ + type: 'with_imported_schema', + isReady: () => true, + schema: externallyDefinedSchema, + fetch(): Usage { + return { + locale: 'en', + }; + }, +}); diff --git a/src/fixtures/telemetry_collectors/imported_usage_interface.ts b/src/fixtures/telemetry_collectors/imported_usage_interface.ts new file mode 100644 index 00000000000000..a4a0f4ae1b3c4a --- /dev/null +++ b/src/fixtures/telemetry_collectors/imported_usage_interface.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; +import { Usage } from './constants'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +export const myCollector = makeUsageCollector({ + type: 'imported_usage_interface_collector', + isReady: () => true, + fetch() { + return { + locale: 'en', + }; + }, + schema: { + locale: { + type: 'keyword', + }, + }, +}); diff --git a/src/fixtures/telemetry_collectors/nested_collector.ts b/src/fixtures/telemetry_collectors/nested_collector.ts new file mode 100644 index 00000000000000..bde89fe4a70603 --- /dev/null +++ b/src/fixtures/telemetry_collectors/nested_collector.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet, UsageCollector } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const collectorSet = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + locale?: string; +} + +export class NestedInside { + collector?: UsageCollector; + createMyCollector() { + this.collector = collectorSet.makeUsageCollector({ + type: 'my_nested_collector', + isReady: () => true, + fetch: async () => { + return { + locale: 'en', + }; + }, + schema: { + locale: { + type: 'keyword', + }, + }, + }); + } +} diff --git a/src/fixtures/telemetry_collectors/unmapped_collector.ts b/src/fixtures/telemetry_collectors/unmapped_collector.ts new file mode 100644 index 00000000000000..1ea360fcd9e960 --- /dev/null +++ b/src/fixtures/telemetry_collectors/unmapped_collector.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + locale: string; +} + +export const myCollector = makeUsageCollector({ + type: 'unmapped_collector', + isReady: () => true, + fetch(): Usage { + return { + locale: 'en', + }; + }, +}); diff --git a/src/fixtures/telemetry_collectors/working_collector.ts b/src/fixtures/telemetry_collectors/working_collector.ts new file mode 100644 index 00000000000000..d70a247c61e70a --- /dev/null +++ b/src/fixtures/telemetry_collectors/working_collector.ts @@ -0,0 +1,81 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface MyObject { + total: number; + type: boolean; +} + +interface Usage { + flat?: string; + my_str?: string; + my_objects: MyObject; +} + +const SOME_NUMBER: number = 123; + +export const myCollector = makeUsageCollector({ + type: 'my_working_collector', + isReady: () => true, + fetch() { + const testString = '123'; + // query ES and get some data + + // summarize the data into a model + // return the modeled object that includes whatever you want to track + try { + return { + flat: 'hello', + my_str: testString, + my_objects: { + total: SOME_NUMBER, + type: true, + }, + }; + } catch (err) { + return { + my_objects: { + total: 0, + type: true, + }, + }; + } + }, + schema: { + flat: { + type: 'keyword', + }, + my_str: { + type: 'text', + }, + my_objects: { + total: { + type: 'number', + }, + type: { type: 'boolean' }, + }, + }, +}); diff --git a/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.test.ts b/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.test.ts index 395cb605878328..63c2cbec21b579 100644 --- a/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.test.ts +++ b/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.test.ts @@ -34,6 +34,7 @@ const createMockKbnServer = () => ({ describe('csp collector', () => { let kbnServer: ReturnType; + const mockCallCluster = null as any; function updateCsp(config: Partial) { kbnServer.newPlatform.setup.core.http.csp = new CspConfig(config); @@ -46,28 +47,28 @@ describe('csp collector', () => { test('fetches whether strict mode is enabled', async () => { const collector = createCspCollector(kbnServer as any); - expect((await collector.fetch()).strict).toEqual(true); + expect((await collector.fetch(mockCallCluster)).strict).toEqual(true); updateCsp({ strict: false }); - expect((await collector.fetch()).strict).toEqual(false); + expect((await collector.fetch(mockCallCluster)).strict).toEqual(false); }); test('fetches whether the legacy browser warning is enabled', async () => { const collector = createCspCollector(kbnServer as any); - expect((await collector.fetch()).warnLegacyBrowsers).toEqual(true); + expect((await collector.fetch(mockCallCluster)).warnLegacyBrowsers).toEqual(true); updateCsp({ warnLegacyBrowsers: false }); - expect((await collector.fetch()).warnLegacyBrowsers).toEqual(false); + expect((await collector.fetch(mockCallCluster)).warnLegacyBrowsers).toEqual(false); }); test('fetches whether the csp rules have been changed or not', async () => { const collector = createCspCollector(kbnServer as any); - expect((await collector.fetch()).rulesChangedFromDefault).toEqual(false); + expect((await collector.fetch(mockCallCluster)).rulesChangedFromDefault).toEqual(false); updateCsp({ rules: ['not', 'default'] }); - expect((await collector.fetch()).rulesChangedFromDefault).toEqual(true); + expect((await collector.fetch(mockCallCluster)).rulesChangedFromDefault).toEqual(true); }); test('does not include raw csp rules under any property names', async () => { @@ -79,7 +80,7 @@ describe('csp collector', () => { // // We use a snapshot here to ensure csp.rules isn't finding its way into the // payload under some new and unexpected variable name (e.g. cspRules). - expect(await collector.fetch()).toMatchInlineSnapshot(` + expect(await collector.fetch(mockCallCluster)).toMatchInlineSnapshot(` Object { "rulesChangedFromDefault": false, "strict": true, diff --git a/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.ts b/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.ts index 6622ed4bef478e..9c124a90e66eb4 100644 --- a/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.ts +++ b/src/legacy/core_plugins/kibana/server/lib/csp_usage_collector/csp_collector.ts @@ -19,9 +19,18 @@ import { Server } from 'hapi'; import { CspConfig } from '../../../../../../core/server'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { + UsageCollectionSetup, + CollectorOptions, +} from '../../../../../../plugins/usage_collection/server'; -export function createCspCollector(server: Server) { +interface Usage { + strict: boolean; + warnLegacyBrowsers: boolean; + rulesChangedFromDefault: boolean; +} + +export function createCspCollector(server: Server): CollectorOptions { return { type: 'csp', isReady: () => true, @@ -37,10 +46,22 @@ export function createCspCollector(server: Server) { rulesChangedFromDefault: header !== CspConfig.DEFAULT.header, }; }, + schema: { + strict: { + type: 'boolean', + }, + warnLegacyBrowsers: { + type: 'boolean', + }, + rulesChangedFromDefault: { + type: 'boolean', + }, + }, }; } export function registerCspCollector(usageCollection: UsageCollectionSetup, server: Server): void { - const collector = usageCollection.makeUsageCollector(createCspCollector(server)); + const collectorConfig = createCspCollector(server); + const collector = usageCollection.makeUsageCollector(collectorConfig); usageCollection.registerCollector(collector); } diff --git a/src/plugins/data/server/kql_telemetry/usage_collector/fetch.ts b/src/plugins/data/server/kql_telemetry/usage_collector/fetch.ts index 157716b38f5234..29f9be903a36f1 100644 --- a/src/plugins/data/server/kql_telemetry/usage_collector/fetch.ts +++ b/src/plugins/data/server/kql_telemetry/usage_collector/fetch.ts @@ -23,8 +23,14 @@ import { DEFAULT_QUERY_LANGUAGE, UI_SETTINGS } from '../../../common'; const defaultSearchQueryLanguageSetting = DEFAULT_QUERY_LANGUAGE; +export interface Usage { + optInCount: number; + optOutCount: number; + defaultQueryLanguage: string; +} + export function fetchProvider(index: string) { - return async (callCluster: APICaller) => { + return async (callCluster: APICaller): Promise => { const [response, config] = await Promise.all([ callCluster('get', { index, @@ -38,7 +44,7 @@ export function fetchProvider(index: string) { }), ]); - const queryLanguageConfigValue = get( + const queryLanguageConfigValue: string | null | undefined = get( config, `hits.hits[0]._source.config.${UI_SETTINGS.SEARCH_QUERY_LANGUAGE}` ); diff --git a/src/plugins/data/server/kql_telemetry/usage_collector/make_kql_usage_collector.ts b/src/plugins/data/server/kql_telemetry/usage_collector/make_kql_usage_collector.ts index db4c9a8f0b4c79..6d0ca00122018f 100644 --- a/src/plugins/data/server/kql_telemetry/usage_collector/make_kql_usage_collector.ts +++ b/src/plugins/data/server/kql_telemetry/usage_collector/make_kql_usage_collector.ts @@ -17,18 +17,22 @@ * under the License. */ -import { fetchProvider } from './fetch'; +import { fetchProvider, Usage } from './fetch'; import { UsageCollectionSetup } from '../../../../usage_collection/server'; export async function makeKQLUsageCollector( usageCollection: UsageCollectionSetup, kibanaIndex: string ) { - const fetch = fetchProvider(kibanaIndex); - const kqlUsageCollector = usageCollection.makeUsageCollector({ + const kqlUsageCollector = usageCollection.makeUsageCollector({ type: 'kql', - fetch, + fetch: fetchProvider(kibanaIndex), isReady: () => true, + schema: { + optInCount: { type: 'long' }, + optOutCount: { type: 'long' }, + defaultQueryLanguage: { type: 'keyword' }, + }, }); usageCollection.registerCollector(kqlUsageCollector); diff --git a/src/plugins/home/server/services/sample_data/usage/collector.ts b/src/plugins/home/server/services/sample_data/usage/collector.ts index 19ceceb4cba143..d819d67a8d4324 100644 --- a/src/plugins/home/server/services/sample_data/usage/collector.ts +++ b/src/plugins/home/server/services/sample_data/usage/collector.ts @@ -19,7 +19,7 @@ import { PluginInitializerContext } from 'kibana/server'; import { first } from 'rxjs/operators'; -import { fetchProvider } from './collector_fetch'; +import { fetchProvider, TelemetryResponse } from './collector_fetch'; import { UsageCollectionSetup } from '../../../../../usage_collection/server'; export async function makeSampleDataUsageCollector( @@ -33,10 +33,18 @@ export async function makeSampleDataUsageCollector( } catch (err) { return; // kibana plugin is not enabled (test environment) } - const collector = usageCollection.makeUsageCollector({ + const collector = usageCollection.makeUsageCollector({ type: 'sample-data', fetch: fetchProvider(index), isReady: () => true, + schema: { + installed: { type: 'keyword' }, + last_install_date: { type: 'date' }, + last_install_set: { type: 'keyword' }, + last_uninstall_date: { type: 'date' }, + last_uninstall_set: { type: 'keyword' }, + uninstalled: { type: 'keyword' }, + }, }); usageCollection.registerCollector(collector); diff --git a/src/plugins/home/server/services/sample_data/usage/collector_fetch.ts b/src/plugins/home/server/services/sample_data/usage/collector_fetch.ts index 4c7316c8530181..d43458cfc64db8 100644 --- a/src/plugins/home/server/services/sample_data/usage/collector_fetch.ts +++ b/src/plugins/home/server/services/sample_data/usage/collector_fetch.ts @@ -31,7 +31,7 @@ interface SearchHit { }; } -interface TelemetryResponse { +export interface TelemetryResponse { installed: string[]; uninstalled: string[]; last_install_date: moment.Moment | null; diff --git a/src/plugins/kibana_usage_collection/common/constants.ts b/src/plugins/kibana_usage_collection/common/constants.ts index df0adfc52184b5..c4e7eaac51cf46 100644 --- a/src/plugins/kibana_usage_collection/common/constants.ts +++ b/src/plugins/kibana_usage_collection/common/constants.ts @@ -20,27 +20,6 @@ export const PLUGIN_ID = 'kibanaUsageCollection'; export const PLUGIN_NAME = 'kibana_usage_collection'; -/** - * UI metric usage type - */ -export const UI_METRIC_USAGE_TYPE = 'ui_metric'; - -/** - * Application Usage type - */ -export const APPLICATION_USAGE_TYPE = 'application_usage'; - -/** - * The type name used within the Monitoring index to publish management stats. - */ -export const KIBANA_STACK_MANAGEMENT_STATS_TYPE = 'stack_management'; - -/** - * The type name used to publish Kibana usage stats. - * NOTE: this string shows as-is in the stats API as a field name for the kibana usage stats - */ -export const KIBANA_USAGE_TYPE = 'kibana'; - /** * The type name used to publish Kibana usage stats in the formatted as bulk. */ diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index f52687038bbbca..1f22ab01001010 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -20,7 +20,6 @@ import moment from 'moment'; import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { APPLICATION_USAGE_TYPE } from '../../../common/constants'; import { findAll } from '../find_all'; import { ApplicationUsageTotal, @@ -62,7 +61,7 @@ export function registerApplicationUsageCollector( registerMappings(registerType); const collector = usageCollection.makeUsageCollector({ - type: APPLICATION_USAGE_TYPE, + type: 'application_usage', isReady: () => typeof getSavedObjectsClient() !== 'undefined', fetch: async () => { const savedObjectsClient = getSavedObjectsClient(); diff --git a/src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts index d0da6fcc523cc4..9cc079a9325d53 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts @@ -21,7 +21,7 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { SharedGlobalConfig } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { KIBANA_STATS_TYPE, KIBANA_USAGE_TYPE } from '../../../common/constants'; +import { KIBANA_STATS_TYPE } from '../../../common/constants'; import { getSavedObjectsCounts } from './get_saved_object_counts'; export function getKibanaUsageCollector( @@ -29,7 +29,7 @@ export function getKibanaUsageCollector( legacyConfig$: Observable ) { return usageCollection.makeUsageCollector({ - type: KIBANA_USAGE_TYPE, + type: 'kibana', isReady: () => true, async fetch(callCluster) { const { diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts index 39cd3518849550..3a777beebd90a7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts @@ -19,7 +19,6 @@ import { IUiSettingsClient } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { KIBANA_STACK_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; export type UsageStats = Record; @@ -47,7 +46,7 @@ export function registerManagementUsageCollector( getUiSettingsClient: () => IUiSettingsClient | undefined ) { const collector = usageCollection.makeUsageCollector({ - type: KIBANA_STACK_MANAGEMENT_STATS_TYPE, + type: 'stack_management', isReady: () => typeof getUiSettingsClient() !== 'undefined', fetch: createCollectorFetch(getUiSettingsClient), }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index 603742f612a6bd..ec2f1bfdfc25f9 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -23,7 +23,6 @@ import { SavedObjectsServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { UI_METRIC_USAGE_TYPE } from '../../../common/constants'; import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { @@ -49,7 +48,7 @@ export function registerUiMetricUsageCollector( }); const collector = usageCollection.makeUsageCollector({ - type: UI_METRIC_USAGE_TYPE, + type: 'ui_metric', fetch: async () => { const savedObjectsClient = getSavedObjectsClient(); if (typeof savedObjectsClient === 'undefined') { diff --git a/src/plugins/telemetry/common/constants.ts b/src/plugins/telemetry/common/constants.ts index 53c79b738f750e..fc77332c18fc90 100644 --- a/src/plugins/telemetry/common/constants.ts +++ b/src/plugins/telemetry/common/constants.ts @@ -56,11 +56,6 @@ export const PATH_TO_ADVANCED_SETTINGS = 'management/kibana/settings'; */ export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-statement`; -/** - * The type name used to publish telemetry plugin stats. - */ -export const TELEMETRY_STATS_TYPE = 'telemetry'; - /** * The endpoint version when hitting the remote telemetry service */ diff --git a/src/plugins/telemetry/schema/legacy_oss_plugins.json b/src/plugins/telemetry/schema/legacy_oss_plugins.json new file mode 100644 index 00000000000000..e660ccac9dc36b --- /dev/null +++ b/src/plugins/telemetry/schema/legacy_oss_plugins.json @@ -0,0 +1,17 @@ +{ + "properties": { + "csp": { + "properties": { + "strict": { + "type": "boolean" + }, + "warnLegacyBrowsers": { + "type": "boolean" + }, + "rulesChangedFromDefault": { + "type": "boolean" + } + } + } + } +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json new file mode 100644 index 00000000000000..a5172c01b1dad2 --- /dev/null +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -0,0 +1,59 @@ +{ + "properties": { + "kql": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + }, + "defaultQueryLanguage": { + "type": "keyword" + } + } + }, + "sample-data": { + "properties": { + "installed": { + "type": "keyword" + }, + "last_install_date": { + "type": "date" + }, + "last_install_set": { + "type": "keyword" + }, + "last_uninstall_date": { + "type": "date" + }, + "last_uninstall_set": { + "type": "keyword" + }, + "uninstalled": { + "type": "keyword" + } + } + }, + "telemetry": { + "properties": { + "opt_in_status": { + "type": "boolean" + }, + "usage_fetcher": { + "type": "keyword" + }, + "last_reported": { + "type": "long" + } + } + }, + "tsvb-validation": { + "properties": { + "failed_validations": { + "type": "long" + } + } + } + } +} diff --git a/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts index ab90935266d69e..05836b8448a688 100644 --- a/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts +++ b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts @@ -20,7 +20,6 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { ISavedObjectsRepository, SavedObjectsClient } from '../../../../../core/server'; -import { TELEMETRY_STATS_TYPE } from '../../../common/constants'; import { getTelemetrySavedObject, TelemetrySavedObject } from '../../telemetry_repository'; import { getTelemetryOptIn, getTelemetrySendUsageFrom } from '../../../common/telemetry_config'; import { UsageCollectionSetup } from '../../../../usage_collection/server'; @@ -81,10 +80,15 @@ export function registerTelemetryPluginUsageCollector( usageCollection: UsageCollectionSetup, options: TelemetryPluginUsageCollectorOptions ) { - const collector = usageCollection.makeUsageCollector({ - type: TELEMETRY_STATS_TYPE, + const collector = usageCollection.makeUsageCollector({ + type: 'telemetry', isReady: () => typeof options.getSavedObjectsClient() !== 'undefined', fetch: createCollectorFetch(options), + schema: { + opt_in_status: { type: 'boolean' }, + usage_fetcher: { type: 'keyword' }, + last_reported: { type: 'long' }, + }, }); usageCollection.registerCollector(collector); diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 99075d5d48f596..9520dfc03cfa47 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -8,7 +8,7 @@ To integrate with the telemetry services for usage collection of your feature, t ## Creating and Registering Usage Collector -All you need to provide is a `type` for organizing your fields, and a `fetch` method for returning your usage data. Then you need to make the Telemetry service aware of the collector by registering it. +All you need to provide is a `type` for organizing your fields, `schema` field to define the expected types of usage fields reported, and a `fetch` method for returning your usage data. Then you need to make the Telemetry service aware of the collector by registering it. ### New Platform @@ -45,6 +45,12 @@ All you need to provide is a `type` for organizing your fields, and a `fetch` me import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APICluster } from 'kibana/server'; + interface Usage { + my_objects: { + total: number, + }, + } + export function registerMyPluginUsageCollector(usageCollection?: UsageCollectionSetup): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. if (!usageCollection) { @@ -52,8 +58,13 @@ All you need to provide is a `type` for organizing your fields, and a `fetch` me } // create usage collector - const myCollector = usageCollection.makeUsageCollector({ + const myCollector = usageCollection.makeUsageCollector({ type: MY_USAGE_TYPE, + schema: { + my_objects: { + total: 'long', + }, + }, fetch: async (callCluster: APICluster) => { // query ES and get some data @@ -98,10 +109,8 @@ class Plugin { ```ts // server/collectors/register.ts import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { ISavedObjectsRepository } from 'kibana/server'; export function registerMyPluginUsageCollector( - getSavedObjectsRepository: () => ISavedObjectsRepository | undefined, usageCollection?: UsageCollectionSetup ): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. @@ -110,22 +119,52 @@ export function registerMyPluginUsageCollector( } // create usage collector - const myCollector = usageCollection.makeUsageCollector({ - type: MY_USAGE_TYPE, - isReady: () => typeof getSavedObjectsRepository() !== 'undefined', - fetch: async () => { - const savedObjectsRepository = getSavedObjectsRepository()!; - // get something from the savedObjects - - return { my_objects }; - }, - }); + const myCollector = usageCollection.makeUsageCollector(...) // register usage collector usageCollection.registerCollector(myCollector); } ``` +## Schema Field + +The `schema` field is a proscribed data model assists with detecting changes in usage collector payloads. To define the collector schema add a schema field that specifies every possible field reported when registering the collector. Whenever the `schema` field is set or changed please run `node scripts/telemetry_check.js --fix` to update the stored schema json files. + +### Allowed Schema Types + +The `AllowedSchemaTypes` is the list of allowed schema types for the usage fields getting reported: + +``` +'keyword', 'text', 'number', 'boolean', 'long', 'date', 'float' +``` + +### Example + +```ts +export const myCollector = makeUsageCollector({ + type: 'my_working_collector', + isReady: () => true, + fetch() { + return { + my_greeting: 'hello', + some_obj: { + total: 123, + }, + }; + }, + schema: { + my_greeting: { + type: 'keyword', + }, + some_obj: { + total: { + type: 'number', + }, + }, + }, +}); +``` + ## Update the telemetry payload and telemetry cluster field mappings There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster. diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index b4f86f67e798d0..00d55ef1c06db5 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -21,9 +21,33 @@ import { Logger, APICaller } from 'kibana/server'; export type CollectorFormatForBulkUpload = (result: T) => { type: string; payload: U }; +export type AllowedSchemaTypes = + | 'keyword' + | 'text' + | 'number' + | 'boolean' + | 'long' + | 'date' + | 'float'; + +export interface SchemaField { + type: string; +} + +type Purify = { [P in T]: T }[T]; + +export type MakeSchemaFrom = { + [Key in Purify>]: Base[Key] extends Array + ? { type: AllowedSchemaTypes } + : Base[Key] extends object + ? MakeSchemaFrom + : { type: AllowedSchemaTypes }; +}; + export interface CollectorOptions { type: string; init?: Function; + schema?: MakeSchemaFrom; fetch: (callCluster: APICaller) => Promise | T; /* * A hook for allowing the fetched data payload to be organized into a typed diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index e8791138c5e265..04ba7452f99e2d 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -42,7 +42,7 @@ export class CollectorSet { public makeStatsCollector = (options: CollectorOptions) => { return new Collector(this.logger, options); }; - public makeUsageCollector = (options: CollectorOptions) => { + public makeUsageCollector = (options: CollectorOptions) => { return new UsageCollector(this.logger, options); }; diff --git a/src/plugins/usage_collection/server/collector/index.ts b/src/plugins/usage_collection/server/collector/index.ts index 0d3939e1dc681b..1816e845b4d666 100644 --- a/src/plugins/usage_collection/server/collector/index.ts +++ b/src/plugins/usage_collection/server/collector/index.ts @@ -18,5 +18,11 @@ */ export { CollectorSet } from './collector_set'; -export { Collector } from './collector'; +export { + Collector, + AllowedSchemaTypes, + SchemaField, + MakeSchemaFrom, + CollectorOptions, +} from './collector'; export { UsageCollector } from './usage_collector'; diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index a2769c8b4b405f..87761bca9a507a 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -20,6 +20,13 @@ import { PluginInitializerContext } from 'kibana/server'; import { UsageCollectionPlugin } from './plugin'; +export { + AllowedSchemaTypes, + MakeSchemaFrom, + SchemaField, + CollectorOptions, + Collector, +} from './collector'; export { UsageCollectionSetup } from './plugin'; export { config } from './config'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts index 505816d48af528..22e427bed24c3c 100644 --- a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts @@ -24,6 +24,9 @@ import { tsvbTelemetrySavedObjectType } from '../saved_objects'; export interface ValidationTelemetryServiceSetup { logFailedValidation: () => void; } +export interface Usage { + failed_validations: number; +} export class ValidationTelemetryService implements Plugin { private kibanaIndex: string = ''; @@ -43,7 +46,7 @@ export class ValidationTelemetryService implements Plugin({ type: 'tsvb-validation', isReady: () => this.kibanaIndex !== '', fetch: async (callCluster: APICaller) => { @@ -63,6 +66,9 @@ export class ValidationTelemetryService implements Plugin({ type: 'actions', isReady: () => true, - fetch: async (): Promise => { + fetch: async () => { try { const doc = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task diff --git a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts index d2cef0f717e94a..7491508ee0745a 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.ts @@ -13,10 +13,10 @@ export function createAlertsUsageCollector( usageCollection: UsageCollectionSetup, taskManager: TaskManagerStartContract ) { - return usageCollection.makeUsageCollector({ + return usageCollection.makeUsageCollector({ type: 'alerts', isReady: () => true, - fetch: async (): Promise => { + fetch: async () => { try { const doc = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task diff --git a/x-pack/plugins/canvas/common/lib/constants.ts b/x-pack/plugins/canvas/common/lib/constants.ts index f2155d9202939c..f42f4095c26970 100644 --- a/x-pack/plugins/canvas/common/lib/constants.ts +++ b/x-pack/plugins/canvas/common/lib/constants.ts @@ -20,7 +20,6 @@ export const LOCALSTORAGE_PREFIX = `kibana.canvas`; export const LOCALSTORAGE_CLIPBOARD = `${LOCALSTORAGE_PREFIX}.clipboard`; export const SESSIONSTORAGE_LASTPATH = 'lastPath:canvas'; export const FETCH_TIMEOUT = 30000; // 30 seconds -export const CANVAS_USAGE_TYPE = 'canvas'; export const DEFAULT_WORKPAD_CSS = '.canvasPage {\n\n}'; export const DEFAULT_ELEMENT_CSS = '.canvasRenderEl{\n\n}'; export const VALID_IMAGE_TYPES = ['gif', 'jpeg', 'png', 'svg+xml']; diff --git a/x-pack/plugins/canvas/server/collectors/collector.ts b/x-pack/plugins/canvas/server/collectors/collector.ts index e266e9826a47d7..48396d93d13e63 100644 --- a/x-pack/plugins/canvas/server/collectors/collector.ts +++ b/x-pack/plugins/canvas/server/collectors/collector.ts @@ -6,7 +6,6 @@ import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CANVAS_USAGE_TYPE } from '../../common/lib/constants'; import { TelemetryCollector } from '../../types'; import { workpadCollector } from './workpad_collector'; @@ -31,20 +30,16 @@ export function registerCanvasUsageCollector( } const canvasCollector = usageCollection.makeUsageCollector({ - type: CANVAS_USAGE_TYPE, + type: 'canvas', isReady: () => true, fetch: async (callCluster: CallCluster) => { const collectorResults = await Promise.all( collectors.map((collector) => collector(kibanaIndex, callCluster)) ); - return collectorResults.reduce( - (reduction, usage) => { - return { ...reduction, ...usage }; - }, - - {} - ); + return collectorResults.reduce((reduction, usage) => { + return { ...reduction, ...usage }; + }, {}); }, }); diff --git a/x-pack/plugins/cloud/common/constants.ts b/x-pack/plugins/cloud/common/constants.ts index 4fafafb9e42136..b72f68247d02b2 100644 --- a/x-pack/plugins/cloud/common/constants.ts +++ b/x-pack/plugins/cloud/common/constants.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const KIBANA_CLOUD_STATS_TYPE = 'cloud'; export const ELASTIC_SUPPORT_LINK = 'https://support.elastic.co/'; diff --git a/x-pack/plugins/cloud/server/collectors/cloud_usage_collector.ts b/x-pack/plugins/cloud/server/collectors/cloud_usage_collector.ts index f3eb92eeddfbe7..b0495f06e7ad4b 100644 --- a/x-pack/plugins/cloud/server/collectors/cloud_usage_collector.ts +++ b/x-pack/plugins/cloud/server/collectors/cloud_usage_collector.ts @@ -5,17 +5,23 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { KIBANA_CLOUD_STATS_TYPE } from '../../common/constants'; interface Config { isCloudEnabled: boolean; } +interface CloudUsage { + isCloudEnabled: boolean; +} + export function createCloudUsageCollector(usageCollection: UsageCollectionSetup, config: Config) { const { isCloudEnabled } = config; - return usageCollection.makeUsageCollector({ - type: KIBANA_CLOUD_STATS_TYPE, + return usageCollection.makeUsageCollector({ + type: 'cloud', isReady: () => true, + schema: { + isCloudEnabled: { type: 'boolean' }, + }, fetch: () => { return { isCloudEnabled, diff --git a/x-pack/plugins/file_upload/server/telemetry/file_upload_usage_collector.ts b/x-pack/plugins/file_upload/server/telemetry/file_upload_usage_collector.ts index 2c2b1183fd5bf3..81b82c141e46fd 100644 --- a/x-pack/plugins/file_upload/server/telemetry/file_upload_usage_collector.ts +++ b/x-pack/plugins/file_upload/server/telemetry/file_upload_usage_collector.ts @@ -5,15 +5,23 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { getTelemetry, initTelemetry } from './telemetry'; - -const TELEMETRY_TYPE = 'fileUploadTelemetry'; +import { getTelemetry, initTelemetry, Telemetry } from './telemetry'; export function registerFileUploadUsageCollector(usageCollection: UsageCollectionSetup): void { - const fileUploadUsageCollector = usageCollection.makeUsageCollector({ - type: TELEMETRY_TYPE, + const fileUploadUsageCollector = usageCollection.makeUsageCollector({ + type: 'fileUploadTelemetry', isReady: () => true, - fetch: async () => (await getTelemetry()) || initTelemetry(), + fetch: async () => { + const fileUploadUsage = await getTelemetry(); + if (!fileUploadUsage) { + return initTelemetry(); + } + + return fileUploadUsage; + }, + schema: { + filesUploadedTotalCount: { type: 'long' }, + }, }); usageCollection.registerCollector(fileUploadUsageCollector); diff --git a/x-pack/plugins/infra/server/usage/usage_collector.ts b/x-pack/plugins/infra/server/usage/usage_collector.ts index 7be7364c331fa7..598ee21e6f2732 100644 --- a/x-pack/plugins/infra/server/usage/usage_collector.ts +++ b/x-pack/plugins/infra/server/usage/usage_collector.ts @@ -7,8 +7,6 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { InventoryItemType } from '../../common/inventory_models/types'; -const KIBANA_REPORTING_TYPE = 'infraops'; - interface InfraopsSum { infraopsHosts: number; infraopsDocker: number; @@ -24,7 +22,7 @@ export class UsageCollector { public static getUsageCollector(usageCollection: UsageCollectionSetup) { return usageCollection.makeUsageCollector({ - type: KIBANA_REPORTING_TYPE, + type: 'infraops', isReady: () => true, fetch: async () => { return this.getReport(); diff --git a/x-pack/plugins/ml/server/lib/telemetry/ml_usage_collector.ts b/x-pack/plugins/ml/server/lib/telemetry/ml_usage_collector.ts index 21e5dce8e47067..35c6936598c406 100644 --- a/x-pack/plugins/ml/server/lib/telemetry/ml_usage_collector.ts +++ b/x-pack/plugins/ml/server/lib/telemetry/ml_usage_collector.ts @@ -7,12 +7,10 @@ import { CoreSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { getTelemetry, initTelemetry } from './telemetry'; +import { getTelemetry, initTelemetry, Telemetry } from './telemetry'; import { mlTelemetryMappingsType } from './mappings'; import { setInternalRepository } from './internal_repository'; -const TELEMETRY_TYPE = 'mlTelemetry'; - export function initMlTelemetry(coreSetup: CoreSetup, usageCollection: UsageCollectionSetup) { coreSetup.savedObjects.registerType(mlTelemetryMappingsType); registerMlUsageCollector(usageCollection); @@ -22,10 +20,22 @@ export function initMlTelemetry(coreSetup: CoreSetup, usageCollection: UsageColl } function registerMlUsageCollector(usageCollection: UsageCollectionSetup): void { - const mlUsageCollector = usageCollection.makeUsageCollector({ - type: TELEMETRY_TYPE, + const mlUsageCollector = usageCollection.makeUsageCollector({ + type: 'mlTelemetry', isReady: () => true, - fetch: async () => (await getTelemetry()) || initTelemetry(), + schema: { + file_data_visualizer: { + index_creation_count: { type: 'long' }, + }, + }, + fetch: async () => { + const mlUsage = await getTelemetry(); + if (!mlUsage) { + return initTelemetry(); + } + + return mlUsage; + }, }); usageCollection.registerCollector(mlUsageCollector); diff --git a/x-pack/plugins/ml/server/lib/telemetry/telemetry.ts b/x-pack/plugins/ml/server/lib/telemetry/telemetry.ts index bc56e8b2a43722..f2162ff2c3d30a 100644 --- a/x-pack/plugins/ml/server/lib/telemetry/telemetry.ts +++ b/x-pack/plugins/ml/server/lib/telemetry/telemetry.ts @@ -11,7 +11,7 @@ import { getInternalRepository } from './internal_repository'; export const TELEMETRY_DOC_ID = 'ml-telemetry'; -interface Telemetry { +export interface Telemetry { file_data_visualizer: { index_creation_count: number; }; diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 48483c79d1af23..c461c2de4e2ad2 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -54,12 +54,6 @@ export const KBN_SCREENSHOT_HEADER_BLACKLIST_STARTS_WITH_PATTERN = ['proxy-']; export const UI_SETTINGS_CUSTOM_PDF_LOGO = 'xpackReporting:customPdfLogo'; -/** - * The type name used within the Monitoring index to publish reporting stats. - * @type {string} - */ -export const KIBANA_REPORTING_TYPE = 'reporting'; - export const PDF_JOB_TYPE = 'printable_pdf'; export const PNG_JOB_TYPE = 'PNG'; export const CSV_JOB_TYPE = 'csv'; diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts index 364f5187f056c0..100d09a2da7e41 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -8,16 +8,22 @@ import { first, map } from 'rxjs/operators'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ReportingCore } from '../'; -import { KIBANA_REPORTING_TYPE } from '../../common/constants'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { ReportingSetupDeps } from '../types'; import { GetLicense } from './'; import { getReportingUsage } from './get_reporting_usage'; -import { RangeStats } from './types'; +import { ReportingUsageType } from './types'; // places the reporting data as kibana stats const METATYPE = 'kibana_stats'; +interface XpackBulkUpload { + usage: { + xpack: { + reporting: ReportingUsageType; + }; + }; +} /* * @return {Object} kibana usage stats type collection object */ @@ -28,20 +34,19 @@ export function getReportingUsageCollector( exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { - return usageCollection.makeUsageCollector({ - type: KIBANA_REPORTING_TYPE, + return usageCollection.makeUsageCollector({ + type: 'reporting', fetch: (callCluster: CallCluster) => { const config = reporting.getConfig(); return getReportingUsage(config, getLicense, callCluster, exportTypesRegistry); }, isReady, - /* * Format the response data into a model for internal upload * 1. Make this data part of the "kibana_stats" type * 2. Organize the payload in the usage.xpack.reporting namespace of the data payload */ - formatForBulkUpload: (result: RangeStats) => { + formatForBulkUpload: (result: ReportingUsageType) => { return { type: METATYPE, payload: { diff --git a/x-pack/plugins/rollup/server/collectors/register.ts b/x-pack/plugins/rollup/server/collectors/register.ts index 629dd8b180fdd7..c679098bc05bea 100644 --- a/x-pack/plugins/rollup/server/collectors/register.ts +++ b/x-pack/plugins/rollup/server/collectors/register.ts @@ -12,8 +12,6 @@ interface IdToFlagMap { [key: string]: boolean; } -const ROLLUP_USAGE_TYPE = 'rollups'; - // elasticsearch index.max_result_window default value const ES_MAX_RESULT_WINDOW_DEFAULT_VALUE = 1000; @@ -174,13 +172,42 @@ async function fetchRollupVisualizations( }; } +interface Usage { + index_patterns: { + total: number; + }; + saved_searches: { + total: number; + }; + visualizations: { + total: number; + saved_searches: { + total: number; + }; + }; +} + export function registerRollupUsageCollector( usageCollection: UsageCollectionSetup, kibanaIndex: string ): void { - const collector = usageCollection.makeUsageCollector({ - type: ROLLUP_USAGE_TYPE, + const collector = usageCollection.makeUsageCollector({ + type: 'rollups', isReady: () => true, + schema: { + index_patterns: { + total: { type: 'long' }, + }, + saved_searches: { + total: { type: 'long' }, + }, + visualizations: { + saved_searches: { + total: { type: 'long' }, + }, + total: { type: 'long' }, + }, + }, fetch: async (callCluster: CallCluster) => { const rollupIndexPatterns = await fetchRollupIndexPatterns(kibanaIndex, callCluster); const rollupIndexPatternToFlagMap = createIdToFlagMap(rollupIndexPatterns); diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index 11882ca2f1b3a8..33f1aae70ea009 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -16,12 +16,6 @@ export const SPACE_SEARCH_COUNT_THRESHOLD = 8; */ export const MAX_SPACE_INITIALS = 2; -/** - * The type name used within the Monitoring index to publish spaces stats. - * @type {string} - */ -export const KIBANA_SPACES_STATS_TYPE = 'spaces'; - /** * The path to enter a space. */ diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts index fa1a81fe080f8e..9f980df8da1b97 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts @@ -9,7 +9,6 @@ import { take } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { Observable } from 'rxjs'; import { KIBANA_STATS_TYPE_MONITORING } from '../../../monitoring/common/constants'; -import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants'; import { PluginsSetup } from '../plugin'; type CallCluster = ( @@ -118,8 +117,25 @@ export interface UsageStats { enabled: boolean; count?: number; usesFeatureControls?: boolean; - disabledFeatures?: { - [featureId: string]: number; + disabledFeatures: { + indexPatterns?: number; + discover?: number; + canvas?: number; + maps?: number; + siem?: number; + monitoring?: number; + graph?: number; + uptime?: number; + savedObjectsManagement?: number; + timelion?: number; + dev_tools?: number; + advancedSettings?: number; + infrastructure?: number; + visualize?: number; + logs?: number; + dashboard?: number; + ml?: number; + apm?: number; }; } @@ -129,6 +145,11 @@ interface CollectorDeps { licensing: PluginsSetup['licensing']; } +interface BulkUpload { + usage: { + spaces: UsageStats; + }; +} /* * @param {Object} server * @return {Object} kibana usage stats type collection object @@ -137,9 +158,35 @@ export function getSpacesUsageCollector( usageCollection: UsageCollectionSetup, deps: CollectorDeps ) { - return usageCollection.makeUsageCollector({ - type: KIBANA_SPACES_STATS_TYPE, + return usageCollection.makeUsageCollector({ + type: 'spaces', isReady: () => true, + schema: { + usesFeatureControls: { type: 'boolean' }, + disabledFeatures: { + indexPatterns: { type: 'long' }, + discover: { type: 'long' }, + canvas: { type: 'long' }, + maps: { type: 'long' }, + siem: { type: 'long' }, + monitoring: { type: 'long' }, + graph: { type: 'long' }, + uptime: { type: 'long' }, + savedObjectsManagement: { type: 'long' }, + timelion: { type: 'long' }, + dev_tools: { type: 'long' }, + advancedSettings: { type: 'long' }, + infrastructure: { type: 'long' }, + visualize: { type: 'long' }, + logs: { type: 'long' }, + dashboard: { type: 'long' }, + ml: { type: 'long' }, + apm: { type: 'long' }, + }, + available: { type: 'boolean' }, + enabled: { type: 'boolean' }, + count: { type: 'long' }, + }, fetch: async (callCluster: CallCluster) => { const license = await deps.licensing.license$.pipe(take(1)).toPromise(); const available = license.isAvailable; // some form of spaces is available for all valid licenses diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json new file mode 100644 index 00000000000000..13d7c62316040b --- /dev/null +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -0,0 +1,247 @@ +{ + "properties": { + "cloud": { + "properties": { + "isCloudEnabled": { + "type": "boolean" + } + } + }, + "fileUploadTelemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "mlTelemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "rollups": { + "properties": { + "index_patterns": { + "properties": { + "total": { + "type": "long" + } + } + }, + "saved_searches": { + "properties": { + "total": { + "type": "long" + } + } + }, + "visualizations": { + "properties": { + "saved_searches": { + "properties": { + "total": { + "type": "long" + } + } + }, + "total": { + "type": "long" + } + } + } + } + }, + "spaces": { + "properties": { + "usesFeatureControls": { + "type": "boolean" + }, + "disabledFeatures": { + "properties": { + "indexPatterns": { + "type": "long" + }, + "discover": { + "type": "long" + }, + "canvas": { + "type": "long" + }, + "maps": { + "type": "long" + }, + "siem": { + "type": "long" + }, + "monitoring": { + "type": "long" + }, + "graph": { + "type": "long" + }, + "uptime": { + "type": "long" + }, + "savedObjectsManagement": { + "type": "long" + }, + "timelion": { + "type": "long" + }, + "dev_tools": { + "type": "long" + }, + "advancedSettings": { + "type": "long" + }, + "infrastructure": { + "type": "long" + }, + "visualize": { + "type": "long" + }, + "logs": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "ml": { + "type": "long" + }, + "apm": { + "type": "long" + } + } + }, + "available": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "count": { + "type": "long" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "type": "long" + }, + "indices": { + "type": "long" + }, + "overview": { + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "type": "long" + }, + "open": { + "type": "long" + }, + "start": { + "type": "long" + }, + "stop": { + "type": "long" + } + } + } + } + }, + "uptime": { + "properties": { + "last_24_hours": { + "properties": { + "hits": { + "properties": { + "autoRefreshEnabled": { + "type": "boolean" + }, + "autorefreshInterval": { + "type": "long" + }, + "dateRangeEnd": { + "type": "date" + }, + "dateRangeStart": { + "type": "date" + }, + "monitor_frequency": { + "type": "long" + }, + "monitor_name_stats": { + "properties": { + "avg_length": { + "type": "float" + }, + "max_length": { + "type": "long" + }, + "min_length": { + "type": "long" + } + } + }, + "monitor_page": { + "type": "long" + }, + "no_of_unique_monitors": { + "type": "long" + }, + "no_of_unique_observer_locations": { + "type": "long" + }, + "observer_location_name_stats": { + "properties": { + "avg_length": { + "type": "float" + }, + "max_length": { + "type": "long" + }, + "min_length": { + "type": "long" + } + } + }, + "overview_page": { + "type": "long" + }, + "settings_page": { + "type": "long" + } + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts index 0c2e3a1e43f4aa..e511e27ee0e2c8 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts @@ -120,9 +120,29 @@ export function registerUpgradeAssistantUsageCollector({ usageCollection, savedObjects, }: Dependencies) { - const upgradeAssistantUsageCollector = usageCollection.makeUsageCollector({ - type: UPGRADE_ASSISTANT_TYPE, + const upgradeAssistantUsageCollector = usageCollection.makeUsageCollector< + UpgradeAssistantTelemetry + >({ + type: 'upgrade-assistant-telemetry', isReady: () => true, + schema: { + features: { + deprecation_logging: { + enabled: { type: 'boolean' }, + }, + }, + ui_open: { + cluster: { type: 'long' }, + indices: { type: 'long' }, + overview: { type: 'long' }, + }, + ui_reindex: { + close: { type: 'long' }, + open: { type: 'long' }, + start: { type: 'long' }, + stop: { type: 'long' }, + }, + }, fetch: async () => fetchUpgradeAssistantMetrics(elasticsearch, savedObjects), }); diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts index 5d93a4d7f356d8..44b95515039d88 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import { ISavedObjectsRepository, SavedObjectsClientContract } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PageViewParams, UptimeTelemetry } from './types'; +import { PageViewParams, UptimeTelemetry, Usage } from './types'; import { APICaller } from '../framework'; import { savedObjectsAdapter } from '../../saved_objects'; @@ -39,8 +39,36 @@ export class KibanaTelemetryAdapter { usageCollector: UsageCollectionSetup, getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { - return usageCollector.makeUsageCollector({ + return usageCollector.makeUsageCollector({ type: 'uptime', + schema: { + last_24_hours: { + hits: { + autoRefreshEnabled: { + type: 'boolean', + }, + autorefreshInterval: { type: 'long' }, + dateRangeEnd: { type: 'date' }, + dateRangeStart: { type: 'date' }, + monitor_frequency: { type: 'long' }, + monitor_name_stats: { + avg_length: { type: 'float' }, + max_length: { type: 'long' }, + min_length: { type: 'long' }, + }, + monitor_page: { type: 'long' }, + no_of_unique_monitors: { type: 'long' }, + no_of_unique_observer_locations: { type: 'long' }, + observer_location_name_stats: { + avg_length: { type: 'float' }, + max_length: { type: 'long' }, + min_length: { type: 'long' }, + }, + overview_page: { type: 'long' }, + settings_page: { type: 'long' }, + }, + }, + }, fetch: async (callCluster: APICaller) => { const savedObjectsClient = getSavedObjectsClient()!; if (savedObjectsClient) { diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts index ee3360ecc41b18..f2afeb2b7e50e0 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts @@ -19,6 +19,12 @@ export interface Stats { avg_length: number; } +export interface Usage { + last_24_hours: { + hits: UptimeTelemetry; + }; +} + export interface UptimeTelemetry { overview_page: number; monitor_page: number; From 684289d6e3d27fe0c493f23812c790dca9478bf5 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Fri, 26 Jun 2020 20:25:01 -0400 Subject: [PATCH 05/38] [SECURITY SOLUTION][INGEST] UX update for ingest manager edit/create datasource for endpoint (#70079) [security solution][ingest]UX update for ingest manager edit/create datasource for endpoint --- .../components/endpoint/link_to_app.tsx | 25 +++++-- .../configure_datasource.tsx | 68 ++++++++++++------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx index d6d8859b280b85..a12611ea270357 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/link_to_app.tsx @@ -5,10 +5,10 @@ */ import React, { memo, MouseEventHandler } from 'react'; -import { EuiLink, EuiLinkProps } from '@elastic/eui'; +import { EuiLink, EuiLinkProps, EuiButton, EuiButtonProps } from '@elastic/eui'; import { useNavigateToAppEventHandler } from '../../hooks/endpoint/use_navigate_to_app_event_handler'; -type LinkToAppProps = EuiLinkProps & { +type LinkToAppProps = (EuiLinkProps | EuiButtonProps) & { /** the app id - normally the value of the `id` in that plugin's `kibana.json` */ appId: string; /** Any app specific path (route) */ @@ -16,6 +16,8 @@ type LinkToAppProps = EuiLinkProps & { // eslint-disable-next-line @typescript-eslint/no-explicit-any appState?: any; onClick?: MouseEventHandler; + /** Uses an EuiButton element for styling */ + asButton?: boolean; }; /** @@ -23,13 +25,22 @@ type LinkToAppProps = EuiLinkProps & { * a given app without causing a full browser refresh */ export const LinkToApp = memo( - ({ appId, appPath: path, appState: state, onClick, children, ...otherProps }) => { + ({ appId, appPath: path, appState: state, onClick, asButton, children, ...otherProps }) => { const handleOnClick = useNavigateToAppEventHandler(appId, { path, state, onClick }); + return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - {children} - + <> + {asButton && asButton === true ? ( + + {children} + + ) : ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {children} + + )} + ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx index 7b4dc36def1335..df1591bf78778a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx @@ -6,8 +6,8 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; -import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { EuiCallOut, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; import { CustomConfigureDatasourceContent, @@ -21,43 +21,65 @@ import { getPolicyDetailPath } from '../../../../common/routing'; */ export const ConfigureEndpointDatasource = memo( ({ from, datasourceId }: CustomConfigureDatasourceProps) => { - const { services } = useKibana(); let policyUrl = ''; if (from === 'edit' && datasourceId) { policyUrl = getPolicyDetailPath(datasourceId); } return ( - + <> + +

+ +

+
+ + +

{from === 'edit' ? ( - + <> - + + + + + ) : ( )}

- } - /> +
+ ); } ); From f4e7f14ffeb78d3e5cc266d542087bb60a0a5ecb Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Sat, 27 Jun 2020 04:53:53 +0100 Subject: [PATCH 06/38] [SIEM] Import timeline fix (#65448) * fix import timeline and clean up fix unit tests apply failure checker clean up error message fix update template * add unit tests * clean up common libs * rename variables * add unit tests * fix types * Fix imports * rename file * poc * fix unit test * review * cleanup fallback values * cleanup * check if title exists * fix unit test * add unit test * lint error * put the flag for disableTemplate into common * add immutiable * fix unit * check templateTimelineVersion only when update via import * update template timeline via import with response * add template filter * add filter count * add filter numbers * rename * enable pin events and note under active status * disable comment and pinnedEvents for template timelines * add timelineType for openTimeline * enable note icon for template * add timeline type for propertyLeft * fix types * duplicate elastic template * update schema * fix status check * fix import * add templateTimelineType * disable note for immutable timeline * fix unit * fix error message * fix update * fix types * rollback change * rollback change * fix create template timeline * add i18n for error message * fix unit test * fix wording and disable delete btn for immutable timeline * fix unit test provider * fix types * fix toaster * fix notes and pins * add i18n * fix selected items * set disableTemplateto true * move templateInfo to helper * review + imporvement * fix review * fix types * fix types Co-authored-by: Patryk Kopycinski Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> --- .../security_solution/common/constants.ts | 8 +- .../common/types/timeline/index.ts | 32 + .../components/alerts_table/actions.test.tsx | 4 +- .../index.test.tsx | 17 +- .../error_toast_dispatcher/index.test.tsx | 17 +- .../common/components/inspect/index.test.tsx | 25 +- .../components/stat_items/index.test.tsx | 9 +- .../super_date_picker/index.test.tsx | 17 +- .../common/components/top_n/index.test.tsx | 9 +- .../common/lib/compose/kibana_compose.tsx | 2 +- .../mock/endpoint/app_context_render.tsx | 25 +- .../public/common/mock/kibana_react.ts | 33 + .../public/common/mock/test_providers.tsx | 19 +- .../public/common/store/store.ts | 3 + .../public/common/store/types.ts | 21 +- .../view/test_helpers/render_alert_page.tsx | 9 +- .../public/graphql/introspection.json | 78 +- .../security_solution/public/graphql/types.ts | 32 + .../authentications_table/index.test.tsx | 17 +- .../components/hosts_table/index.test.tsx | 17 +- .../public/hosts/pages/hosts.test.tsx | 9 +- .../components/ip_overview/index.test.tsx | 17 +- .../components/kpi_network/index.test.tsx | 17 +- .../network_dns_table/index.test.tsx | 17 +- .../network_http_table/index.test.tsx | 17 +- .../index.test.tsx | 17 +- .../network_top_n_flow_table/index.test.tsx | 17 +- .../components/tls_table/index.test.tsx | 17 +- .../components/users_table/index.test.tsx | 17 +- .../network/pages/ip_details/index.test.tsx | 20 +- .../public/network/pages/network.test.tsx | 9 +- .../components/overview_host/index.test.tsx | 17 +- .../overview_network/index.test.tsx | 17 +- .../components/recent_timelines/index.tsx | 39 +- .../security_solution/public/plugin.tsx | 16 +- .../components/flyout/header/index.tsx | 4 + .../__snapshots__/index.test.tsx.snap | 1 + .../header_with_close_button/index.test.tsx | 23 +- .../components/flyout/index.test.tsx | 5 + .../components/notes/add_note/index.test.tsx | 180 ++-- .../components/notes/add_note/index.tsx | 1 - .../timelines/components/notes/index.tsx | 21 +- .../notes/note_cards/index.test.tsx | 65 +- .../components/notes/note_cards/index.tsx | 3 + .../edit_timeline_batch_actions.tsx | 11 +- .../export_timeline/export_timeline.test.tsx | 27 - .../export_timeline/index.test.tsx | 44 +- .../open_timeline/export_timeline/index.tsx | 24 +- .../components/open_timeline/helpers.ts | 192 +++-- .../components/open_timeline/index.tsx | 94 +- .../open_timeline/open_timeline.test.tsx | 4 +- .../open_timeline/open_timeline.tsx | 36 +- .../open_timeline_modal_body.test.tsx | 4 +- .../open_timeline_modal_body.tsx | 22 +- .../open_timeline/search_row/index.tsx | 15 +- .../timelines_table/actions_columns.tsx | 8 +- .../timelines_table/icon_header_columns.tsx | 105 ++- .../open_timeline/timelines_table/index.tsx | 8 +- .../open_timeline/timelines_table/mocks.ts | 2 + .../components/open_timeline/translations.ts | 23 +- .../components/open_timeline/types.ts | 30 +- .../open_timeline/use_timeline_status.tsx | 110 +++ .../open_timeline/use_timeline_types.tsx | 116 +-- .../__snapshots__/timeline.test.tsx.snap | 1 + .../timeline/body/actions/index.test.tsx | 13 +- .../timeline/body/actions/index.tsx | 168 ++-- .../timeline/body/events/stateful_event.tsx | 9 +- .../components/timeline/body/helpers.test.ts | 29 +- .../components/timeline/body/helpers.ts | 12 +- .../components/timeline/body/index.test.tsx | 240 ++---- .../components/timeline/body/translations.ts | 14 + .../components/timeline/header/index.test.tsx | 82 +- .../components/timeline/header/index.tsx | 19 +- .../timeline/header/translations.ts | 8 + .../components/timeline/index.test.tsx | 3 + .../timelines/components/timeline/index.tsx | 10 +- .../components/timeline/pin/index.tsx | 26 +- .../timeline/properties/helpers.tsx | 44 +- .../timeline/properties/index.test.tsx | 56 +- .../components/timeline/properties/index.tsx | 9 +- .../properties/new_template_timeline.test.tsx | 9 +- .../timeline/properties/properties_left.tsx | 9 + .../properties/properties_right.test.tsx | 3 +- .../timeline/properties/properties_right.tsx | 8 +- .../timeline/properties/translations.ts | 2 +- .../selectable_timeline/index.test.tsx | 8 +- .../timeline/selectable_timeline/index.tsx | 45 +- .../components/timeline/timeline.test.tsx | 2 + .../components/timeline/timeline.tsx | 4 + .../containers/all/index.gql_query.ts | 9 + .../public/timelines/containers/all/index.tsx | 61 +- .../public/timelines/containers/api.test.ts | 7 +- .../public/timelines/containers/api.ts | 65 +- .../public/timelines/pages/translations.ts | 14 + .../timelines/store/timeline/actions.ts | 2 + .../public/timelines/store/timeline/epic.ts | 60 +- .../timeline/epic_local_storage.test.tsx | 19 +- .../timelines/store/timeline/helpers.ts | 57 +- .../store/timeline/manage_timeline_id.tsx | 18 + .../public/timelines/store/timeline/model.ts | 1 - .../timelines/store/timeline/reducer.ts | 38 +- .../public/timelines/store/timeline/types.ts | 3 + .../plugins/security_solution/public/types.ts | 5 + .../server/graphql/timeline/resolvers.ts | 4 +- .../server/graphql/timeline/schema.gql.ts | 13 +- .../security_solution/server/graphql/types.ts | 67 ++ .../server/lib/detection_engine/README.md | 4 +- .../lib/timeline/pick_saved_timeline.ts | 20 +- .../routes/__mocks__/import_timelines.ts | 56 +- .../routes/__mocks__/request_responses.ts | 16 +- .../routes/clean_draft_timelines_route.ts | 12 +- .../routes/create_timelines_route.test.ts | 10 +- .../timeline/routes/create_timelines_route.ts | 85 +- .../routes/import_timelines_route.test.ts | 517 ++++++++++- .../timeline/routes/import_timelines_route.ts | 157 ++-- .../routes/update_timelines_route.test.ts | 8 +- .../timeline/routes/update_timelines_route.ts | 85 +- .../lib/timeline/routes/utils/common.ts | 21 +- .../utils/compare_timelines_status.test.ts | 810 ++++++++++++++++++ .../routes/utils/compare_timelines_status.ts | 247 ++++++ .../timeline/routes/utils/create_timelines.ts | 47 +- .../timeline/routes/utils/export_timelines.ts | 8 +- .../timeline/routes/utils/failure_cases.ts | 377 ++++++++ .../timeline/routes/utils/timeline_object.ts | 86 ++ .../timeline/routes/utils/update_timelines.ts | 80 -- .../server/lib/timeline/saved_object.ts | 144 +++- 126 files changed, 4536 insertions(+), 1365 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 58431e405ea8b6..4aff1c81c40f71 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -158,6 +158,12 @@ export const showAllOthersBucket: string[] = [ /** * CreateTemplateTimelineBtn + * https://github.com/elastic/kibana/pull/66613 * Remove the comment here to enable template timeline */ -export const disableTemplate = true; +export const disableTemplate = false; + +/* + * This should be set to true after https://github.com/elastic/kibana/pull/67496 is merged + */ +export const enableElasticFilter = false; 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 4f255bb6d68341..2cf5930a83bee2 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -137,11 +137,13 @@ const SavedSortRuntimeType = runtimeTypes.partial({ export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export const TimelineStatusLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineStatus.active), runtimeTypes.literal(TimelineStatus.draft), + runtimeTypes.literal(TimelineStatus.immutable), ]); const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt); @@ -151,6 +153,29 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf< typeof TimelineStatusLiteralWithNullRt >; +/** + * Template timeline type + */ + +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + +export const TemplateTimelineTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TemplateTimelineType.elastic), + runtimeTypes.literal(TemplateTimelineType.custom), +]); + +export const TemplateTimelineTypeLiteralWithNullRt = unionWithNullType( + TemplateTimelineTypeLiteralRt +); + +export type TemplateTimelineTypeLiteral = runtimeTypes.TypeOf; +export type TemplateTimelineTypeLiteralWithNull = runtimeTypes.TypeOf< + typeof TemplateTimelineTypeLiteralWithNullRt +>; + /* * Timeline Types */ @@ -273,6 +298,13 @@ export const TimelineResponseType = runtimeTypes.type({ }), }); +export const TimelineErrorResponseType = runtimeTypes.type({ + status_code: runtimeTypes.number, + message: runtimeTypes.string, +}); + +export interface TimelineErrorResponse + extends runtimeTypes.TypeOf {} export interface TimelineResponse extends runtimeTypes.TypeOf {} /** diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx index 2fa7cfeedcd155..bd62b79a3c54e6 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx @@ -215,8 +215,8 @@ describe('alert actions', () => { columnId: '@timestamp', sortDirection: 'desc', }, - status: TimelineStatus.draft, - title: '', + status: TimelineStatus.active, + title: 'Test rule - Duplicate', timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, diff --git a/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx index c7015ed81701ef..9c08e05ddfa399 100644 --- a/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx @@ -12,6 +12,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore, State } from '../../store'; @@ -35,10 +36,22 @@ jest.mock('../../lib/kibana', () => ({ describe('AddFilterToGlobalSearchBar Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); mockAddFilters.mockClear(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx index 4bc77555f09bdd..45b75d0f33ac9c 100644 --- a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx @@ -12,6 +12,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore } from '../../store/store'; @@ -22,10 +23,22 @@ import { State } from '../../store/types'; describe('Error Toast Dispatcher', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx index 45397921a6651d..f2b7d45972625e 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore, State } from '../../store'; @@ -36,13 +37,25 @@ describe('Inspect Button', () => { state: state.inputs, }; - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); describe('Render', () => { beforeEach(() => { const myState = cloneDeep(state); myState.inputs = upsertQuery(newQuery); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('Eui Empty Button', () => { const wrapper = mount( @@ -146,7 +159,13 @@ describe('Inspect Button', () => { response: ['my response'], }; myState.inputs = upsertQuery(myQuery); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('Open Inspect Modal', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx index 50721ef3b26ad7..f548275b36e709 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx @@ -34,6 +34,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { State, createStore } from '../../store'; @@ -55,7 +56,13 @@ describe('Stat Items Component', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); describe.each([ [ diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx index 19321622d75fa8..164ca177ee91ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx @@ -14,6 +14,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createUseUiSetting$Mock } from '../../mock/kibana_react'; @@ -81,11 +82,23 @@ describe('SIEM Super Date Picker', () => { describe('#SuperDatePicker', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { jest.clearAllMocks(); - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); mockUseUiSetting$.mockImplementation((key, defaultValue) => { const useUiSetting$Mock = createUseUiSetting$Mock(); diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index ae25e66b2af866..336f906b3bed04 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -13,6 +13,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; @@ -156,7 +157,13 @@ const state: State = { }; const { storage } = createSecuritySolutionStorageMock(); -const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); +const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage +); describe('StatefulTopN', () => { // Suppress warnings about "react-beautiful-dnd" diff --git a/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx b/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx index 47834f148c9102..342db7f43943d4 100644 --- a/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx @@ -9,10 +9,10 @@ import ApolloClient from 'apollo-client'; import { ApolloLink } from 'apollo-link'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CoreStart } from '../../../../../../../src/core/public'; import introspectionQueryResultData from '../../../graphql/introspection.json'; import { AppFrontendLibs } from '../lib'; import { getLinks } from './helpers'; +import { CoreStart } from '../../../../../../../src/core/public'; export function composeLibs(core: CoreStart): AppFrontendLibs { const cache = new InMemoryCache({ diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 1db63897a88633..779d5eff0b971b 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -13,7 +13,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { StartPlugins } from '../../../types'; import { depsStartMock } from './dependencies_start_mock'; import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils'; -import { apolloClientObservable } from '../test_providers'; +import { apolloClientObservable, kibanaObservable } from '../test_providers'; import { createStore, State, substateMiddlewareFactory } from '../../store'; import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware'; import { AppRootProvider } from './app_root_provider'; @@ -58,14 +58,21 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { const middlewareSpy = createSpyMiddleware(); const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage, [ - substateMiddlewareFactory( - (globalState) => globalState.alertList, - alertMiddlewareFactory(coreStart, depsStart) - ), - ...managementMiddlewareFactory(coreStart, depsStart), - middlewareSpy.actionSpyMiddleware, - ]); + const store = createStore( + mockGlobalState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage, + [ + substateMiddlewareFactory( + (globalState) => globalState.alertList, + alertMiddlewareFactory(coreStart, depsStart) + ), + ...managementMiddlewareFactory(coreStart, depsStart), + middlewareSpy.actionSpyMiddleware, + ] + ); const MockKibanaContextProvider = createKibanaContextProviderMock(); diff --git a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts index 2b639bfdc14f5d..c5d50e1379482b 100644 --- a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts @@ -26,6 +26,7 @@ import { DEFAULT_INDEX_PATTERN, } from '../../../common/constants'; import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core'; +import { StartServices } from '../../types'; import { createSecuritySolutionStorageMock } from './mock_local_storage'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -71,6 +72,8 @@ export const createUseUiSetting$Mock = () => { ): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()]; }; +export const createKibanaObservable$Mock = createKibanaCoreStartMock; + export const createUseKibanaMock = () => { const core = createKibanaCoreStartMock(); const plugins = createKibanaPluginsStartMock(); @@ -90,6 +93,36 @@ export const createUseKibanaMock = () => { return () => ({ services }); }; +export const createStartServices = () => { + const core = createKibanaCoreStartMock(); + const plugins = createKibanaPluginsStartMock(); + const security = { + authc: { + getCurrentUser: jest.fn(), + areAPIKeysEnabled: jest.fn(), + }, + sessionTimeout: { + start: jest.fn(), + stop: jest.fn(), + extend: jest.fn(), + }, + license: { + isEnabled: jest.fn(), + getFeatures: jest.fn(), + features$: jest.fn(), + }, + __legacyCompat: { logoutUrl: 'logoutUrl', tenant: 'tenant' }, + }; + + const services = ({ + ...core, + ...plugins, + security, + } as unknown) as StartServices; + + return services; +}; + export const createWithKibanaMock = () => { const kibana = createUseKibanaMock()(); diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 0573f049c35c53..297dc235a2a50b 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -19,7 +19,7 @@ import { ThemeProvider } from 'styled-components'; import { createStore, State } from '../store'; import { mockGlobalState } from './global_state'; -import { createKibanaContextProviderMock } from './kibana_react'; +import { createKibanaContextProviderMock, createStartServices } from './kibana_react'; import { FieldHook, useForm } from '../../shared_imports'; import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; @@ -38,6 +38,7 @@ export const apolloClient = new ApolloClient({ }); export const apolloClientObservable = new BehaviorSubject(apolloClient); +export const kibanaObservable = new BehaviorSubject(createStartServices()); Object.defineProperty(window, 'localStorage', { value: localStorageMock(), @@ -49,7 +50,13 @@ const { storage } = createSecuritySolutionStorageMock(); /** A utility for wrapping children in the providers required to run most tests */ const TestProvidersComponent: React.FC = ({ children, - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ), onDragEnd = jest.fn(), }) => ( @@ -69,7 +76,13 @@ export const TestProviders = React.memo(TestProvidersComponent); const TestProviderWithoutDragAndDropComponent: React.FC = ({ children, - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ), }) => ( {children} diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index 5f53724b287df8..a39c9f18bcdb8d 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -29,6 +29,7 @@ import { AppAction } from './actions'; import { Immutable } from '../../../common/endpoint/types'; import { State } from './types'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { CoreStart } from '../../../../../../src/core/public'; type ComposeType = typeof compose; declare global { @@ -49,6 +50,7 @@ export const createStore = ( state: PreloadedState, pluginsReducer: SubPluginsInitReducer, apolloClient: Observable, + kibana: Observable, storage: Storage, additionalMiddleware?: Array>>> ): Store => { @@ -56,6 +58,7 @@ export const createStore = ( const middlewareDependencies = { apolloClient$: apolloClient, + kibana$: kibana, selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector, selectNotesByIdSelector: appSelectors.selectNotesByIdSelector, timelineByIdSelector: timelineSelectors.timelineByIdSelector, diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 2b92451e30119b..d1e8df0f982c4f 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -19,23 +19,22 @@ import { NetworkPluginState } from '../../network/store'; import { EndpointAlertsPluginState } from '../../endpoint_alerts'; import { ManagementPluginState } from '../../management'; +export type StoreState = HostsPluginState & + NetworkPluginState & + TimelinePluginState & + EndpointAlertsPluginState & + ManagementPluginState & { + app: AppState; + dragAndDrop: DragAndDropState; + inputs: InputsState; + }; /** * The redux `State` type for the Security App. * We use `CombinedState` to wrap our shape because we create our reducer using `combineReducers`. * `combineReducers` returns a type wrapped in `CombinedState`. * `CombinedState` is required for redux to know what keys to make optional when preloaded state into a store. */ -export type State = CombinedState< - HostsPluginState & - NetworkPluginState & - TimelinePluginState & - EndpointAlertsPluginState & - ManagementPluginState & { - app: AppState; - dragAndDrop: DragAndDropState; - inputs: InputsState; - } ->; +export type State = CombinedState; export type KueryFilterQueryKind = 'kuery' | 'lucene'; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx index acfe3f228c21fb..f03c72518305d3 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx @@ -19,6 +19,7 @@ import { SUB_PLUGINS_REDUCER, mockGlobalState, apolloClientObservable, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; @@ -31,7 +32,13 @@ export const alertPageTestRender = () => { * Create a store, with the middleware disabled. We don't want side effects being created by our code in this test. */ const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + mockGlobalState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const depsStart = depsStartMock(); depsStart.data.ui.SearchBar.mockImplementation(() =>
); diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 48547212bb6c01..69356f8fc8aa74 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -255,6 +255,18 @@ "description": "", "type": { "kind": "ENUM", "name": "TimelineType", "ofType": null }, "defaultValue": null + }, + { + "name": "templateTimelineType", + "description": "", + "type": { "kind": "ENUM", "name": "TemplateTimelineType", "ofType": null }, + "defaultValue": null + }, + { + "name": "status", + "description": "", + "type": { "kind": "ENUM", "name": "TimelineStatus", "ofType": null }, + "defaultValue": null } ], "type": { @@ -10405,7 +10417,13 @@ "interfaces": null, "enumValues": [ { "name": "active", "description": "", "isDeprecated": false, "deprecationReason": null }, - { "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null } + { "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null }, + { + "name": "immutable", + "description": "", + "isDeprecated": false, + "deprecationReason": null + } ], "possibleTypes": null }, @@ -10529,6 +10547,24 @@ ], "possibleTypes": null }, + { + "kind": "ENUM", + "name": "TemplateTimelineType", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "elastic", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "custom", "description": "", "isDeprecated": false, "deprecationReason": null } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "ResponseTimelines", @@ -10557,6 +10593,46 @@ "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "defaultTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "templateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "elasticTemplateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customTemplateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "favoriteCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index b5088fe51b446b..1171e937935368 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -345,6 +345,7 @@ export enum TlsFields { export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export enum TimelineType { @@ -359,6 +360,11 @@ export enum SortFieldTimeline { created = 'created', } +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + export enum NetworkDirectionEcs { inbound = 'inbound', outbound = 'outbound', @@ -2117,6 +2123,16 @@ export interface ResponseTimelines { timeline: (Maybe)[]; totalCount?: Maybe; + + defaultTimelineCount?: Maybe; + + templateTimelineCount?: Maybe; + + elasticTemplateTimelineCount?: Maybe; + + customTemplateTimelineCount?: Maybe; + + favoriteCount?: Maybe; } export interface Mutation { @@ -2254,6 +2270,10 @@ export interface GetAllTimelineQueryArgs { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -4315,6 +4335,8 @@ export namespace GetAllTimeline { sort?: Maybe; onlyUserFavorite?: Maybe; timelineType?: Maybe; + templateTimelineType?: Maybe; + status?: Maybe; }; export type Query = { @@ -4328,6 +4350,16 @@ export namespace GetAllTimeline { totalCount: Maybe; + defaultTimelineCount: Maybe; + + templateTimelineCount: Maybe; + + elasticTemplateTimelineCount: Maybe; + + customTemplateTimelineCount: Maybe; + + favoriteCount: Maybe; + timeline: (Maybe)[]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx index 3809d848759cc3..9603f30615a123 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx @@ -13,6 +13,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -26,10 +27,22 @@ describe('Authentication Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx index 1231c35f214603..ab00e77a4fa433 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -40,11 +41,23 @@ describe('Hosts Table', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index ea0b32137eb395..1ea3a3020a1d5d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -16,6 +16,7 @@ import { TestProviders, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../common/mock'; import { SiemNavigation } from '../../common/components/navigation'; @@ -154,7 +155,13 @@ describe('Hosts - rendering', () => { }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const myStore = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx index 553cb8c63db987..b8d97f06bf85f8 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -28,10 +29,22 @@ describe('IP Overview Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx index 580a5420f1c345..8acd17d2ce7676 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx @@ -12,6 +12,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -25,10 +26,22 @@ describe('KpiNetwork Component', () => { const narrowDateRange = jest.fn(); const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx index 036ebedd6b88ef..bbbe56715d345e 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { State, createStore } from '../../../common/store'; @@ -28,11 +29,23 @@ describe('NetworkTopNFlow Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx index ac37aaf3091552..72c932c575be33 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -31,11 +32,23 @@ describe('NetworkHttp Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx index 8b1dbc8c558b6e..a1ee0574d8b055 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx @@ -17,6 +17,7 @@ import { mockIndexPattern, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -32,10 +33,22 @@ describe('NetworkTopCountries Table Component', () => { const mount = useMountAppended(); const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx index b14d411810dee0..100ecaa51f4ae7 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -31,11 +32,23 @@ describe('NetworkTopNFlow Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx index acbe974f914d7e..cd2dc926c03bcc 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -28,11 +29,23 @@ describe('Tls Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('Rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx index f0d4d7fbeefc60..3f1762cadd652a 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -30,11 +31,23 @@ describe('Users Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('Rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx index a87eb3d0574479..962a6269f84882 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx @@ -18,6 +18,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -90,7 +91,6 @@ const getMockProps = (ip: string) => ({ describe('Ip Details', () => { const mount = useMountAppended(); - beforeAll(() => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: false, @@ -107,15 +107,27 @@ describe('Ip Details', () => { }); afterAll(() => { - delete (global as GlobalWithFetch).fetch; + jest.resetAllMocks(); }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders', () => { diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index 7cdfdbf0af69a6..af84e1d42b45b9 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../common/mock'; import { State, createStore } from '../../common/store'; @@ -139,7 +140,13 @@ describe('rendering - rendering', () => { }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const myStore = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index d29efa2d44c15c..2b21385004a739 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; @@ -95,11 +96,23 @@ describe('OverviewHost', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { const myState = cloneDeep(state); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders the expected widget title', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index b4b685465dbda4..7a9834ee3ea9a8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -14,6 +14,7 @@ import { TestProviders, SUB_PLUGINS_REDUCER, createSecuritySolutionStorageMock, + kibanaObservable, } from '../../../common/mock'; import { OverviewNetwork } from '.'; @@ -86,11 +87,23 @@ describe('OverviewNetwork', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { const myState = cloneDeep(state); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders the expected widget title', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index 9c149a850bec9d..8f2b3c7495f0d7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -24,6 +24,7 @@ import { RecentTimelines } from './recent_timelines'; import * as i18n from './translations'; import { FilterMode } from './types'; import { LoadingPlaceholders } from '../loading_placeholders'; +import { useTimelineStatus } from '../../../timelines/components/open_timeline/use_timeline_status'; import { useKibana } from '../../../common/lib/kibana'; import { SecurityPageName } from '../../../app/types'; import { APP_ID } from '../../../../common/constants'; @@ -83,25 +84,25 @@ const StatefulRecentTimelinesComponent = React.memo( ); const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); - - useEffect( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: 1, - pageSize: PAGE_SIZE, - }, - search: '', - sort: { - sortField: SortFieldTimeline.updated, - sortOrder: Direction.desc, - }, - onlyUserFavorite: filterBy === 'favorites', - timelineType: TimelineType.default, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterBy] - ); + const timelineType = TimelineType.default; + const { templateTimelineType, timelineStatus } = useTimelineStatus({ timelineType }); + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize: PAGE_SIZE, + }, + search: '', + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, + }, + onlyUserFavorite: filterBy === 'favorites', + status: timelineStatus, + timelineType, + templateTimelineType, + }); + }, [fetchAllTimeline, filterBy, timelineStatus, timelineType, templateTimelineType]); return ( <> diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index b247170a4a5db7..d7e29a466cbf2e 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -23,7 +23,14 @@ import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; import { serviceNowActionType, jiraActionType } from './common/lib/connectors'; -import { PluginSetup, PluginStart, SetupPlugins, StartPlugins, StartServices } from './types'; +import { + PluginSetup, + PluginStart, + SetupPlugins, + StartPlugins, + StartServices, + AppObservableLibs, +} from './types'; import { APP_ID, APP_ICON, @@ -120,6 +127,7 @@ export class Plugin implements IPlugin( notesById, status, timelineId, + timelineType, title, toggleLock, updateDescription, @@ -66,6 +67,7 @@ const StatefulFlyoutHeader = React.memo( noteIds={noteIds} status={status} timelineId={timelineId} + timelineType={timelineType} title={title} toggleLock={toggleLock} updateDescription={updateDescription} @@ -100,6 +102,7 @@ const makeMapStateToProps = () => { title = '', noteIds = emptyNotesId, status, + timelineType, } = timeline; const history = emptyHistory; // TODO: get history from store via selector @@ -116,6 +119,7 @@ const makeMapStateToProps = () => { notesById: getNotesByIds(state), status, title, + timelineType, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap index df96f2a1f7eba9..d0d7a1cd7f5d78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap @@ -4,6 +4,7 @@ exports[`FlyoutHeaderWithCloseButton renders correctly against snapshot 1`] = ` { }); describe('FlyoutHeaderWithCloseButton', () => { + const props = { + onClose: jest.fn(), + timelineId: 'test', + timelineType: TimelineType.default, + usersViewing: ['elastic'], + }; test('renders correctly against snapshot', () => { const EmptyComponent = shallow( - + ); expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot(); @@ -55,13 +58,13 @@ describe('FlyoutHeaderWithCloseButton', () => { test('it should invoke onClose when the close button is clicked', () => { const closeMock = jest.fn(); + const testProps = { + ...props, + onClose: closeMock, + }; const wrapper = mount( - + ); wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 932cde32f3d43c..50578ef0a8e42e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -62,6 +63,7 @@ describe('Flyout', () => { stateShowIsTrue, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -86,6 +88,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -108,6 +111,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -142,6 +146,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx index 1ddf298110a5de..570c0028e0f516 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx @@ -8,52 +8,39 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; import { AddNote } from '.'; +import { TimelineStatus } from '../../../../../common/types/timeline'; describe('AddNote', () => { const note = 'The contents of a new note'; + const props = { + associateNote: jest.fn(), + getNewNoteId: jest.fn(), + newNote: note, + onCancelAddNote: jest.fn(), + updateNewNote: jest.fn(), + updateNote: jest.fn(), + status: TimelineStatus.active, + }; test('renders correctly', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the Cancel button when onCancelAddNote is provided', () => { - const wrapper = mount( - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true); }); test('it invokes onCancelAddNote when the Cancel button is clicked', () => { const onCancelAddNote = jest.fn(); + const testProps = { + ...props, + onCancelAddNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -62,17 +49,12 @@ describe('AddNote', () => { test('it does NOT invoke associateNote when the Cancel button is clicked', () => { const associateNote = jest.fn(); + const testProps = { + ...props, + associateNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -80,47 +62,29 @@ describe('AddNote', () => { }); test('it does NOT render the Cancel button when onCancelAddNote is NOT provided', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + onCancelAddNote: undefined, + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false); }); test('it renders the contents of the note', () => { - const wrapper = mount( - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="add-a-note"]').first().text()).toEqual(note); }); test('it invokes associateNote when the Add Note button is clicked', () => { const associateNote = jest.fn(); - - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: note, + associateNote, + }; + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -129,17 +93,12 @@ describe('AddNote', () => { test('it invokes getNewNoteId when the Add Note button is clicked', () => { const getNewNoteId = jest.fn(); + const testProps = { + ...props, + getNewNoteId, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -148,17 +107,12 @@ describe('AddNote', () => { test('it invokes updateNewNote when the Add Note button is clicked', () => { const updateNewNote = jest.fn(); + const testProps = { + ...props, + updateNewNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -167,17 +121,11 @@ describe('AddNote', () => { test('it invokes updateNote when the Add Note button is clicked', () => { const updateNote = jest.fn(); - - const wrapper = mount( - - ); + const testProps = { + ...props, + updateNote, + }; + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -185,16 +133,11 @@ describe('AddNote', () => { }); test('it does NOT display the markdown formatting hint when a note has NOT been entered', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: '', + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( 'visibility', @@ -203,16 +146,11 @@ describe('AddNote', () => { }); test('it displays the markdown formatting hint when a note has been entered', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: 'We should see a formatting hint now', + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( 'visibility', diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx index d3db1a619600f9..7c211aafdf8c63 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx @@ -61,7 +61,6 @@ export const AddNote = React.memo<{ }), [associateNote, getNewNoteId, newNote, updateNewNote, updateNote] ); - return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx index 42f28f03406798..957b37a0bd1c27 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx @@ -21,12 +21,14 @@ import { AddNote } from './add_note'; import { columns } from './columns'; import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers'; import { NOTES_PANEL_WIDTH, NOTES_PANEL_HEIGHT } from '../timeline/properties/notes_size'; +import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; interface Props { associateNote: AssociateNote; getNotesByIds: (noteIds: string[]) => Note[]; getNewNoteId: GetNewNoteId; noteIds: string[]; + status: TimelineStatusLiteral; updateNote: UpdateNote; } @@ -53,8 +55,9 @@ InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ export const Notes = React.memo( - ({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { + ({ associateNote, getNotesByIds, getNewNoteId, noteIds, status, updateNote }) => { const [newNote, setNewNote] = useState(''); + const isImmutable = status === TimelineStatus.immutable; return ( @@ -63,13 +66,15 @@ export const Notes = React.memo( - + {!isImmutable && ( + + )} { const noteIds = ['abc', 'def']; @@ -38,18 +39,21 @@ describe('NoteCards', () => { }, ]; + const props = { + associateNote: jest.fn(), + getNotesByIds, + getNewNoteId: jest.fn(), + noteIds, + showAddNote: true, + status: TimelineStatus.active, + toggleShowAddNote: jest.fn(), + updateNote: jest.fn(), + }; + test('it renders the notes column when noteIds are specified', () => { const wrapper = mountWithIntl( - + ); @@ -57,17 +61,10 @@ describe('NoteCards', () => { }); test('it does NOT render the notes column when noteIds are NOT specified', () => { + const testProps = { ...props, noteIds: [] }; const wrapper = mountWithIntl( - + ); @@ -77,15 +74,7 @@ describe('NoteCards', () => { test('renders note cards', () => { const wrapper = mountWithIntl( - + ); @@ -102,15 +91,7 @@ describe('NoteCards', () => { test('it shows controls for adding notes when showAddNote is true', () => { const wrapper = mountWithIntl( - + ); @@ -118,17 +99,11 @@ describe('NoteCards', () => { }); test('it does NOT show controls for adding notes when showAddNote is false', () => { + const testProps = { ...props, showAddNote: false }; + const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 3c8fc50e93b893..9d9055e3ad748a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -12,6 +12,7 @@ import { Note } from '../../../../common/lib/note'; import { AddNote } from '../add_note'; import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers'; import { NoteCard } from '../note_card'; +import { TimelineStatusLiteral } from '../../../../../common/types/timeline'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -49,6 +50,7 @@ interface Props { getNewNoteId: GetNewNoteId; noteIds: string[]; showAddNote: boolean; + status: TimelineStatusLiteral; toggleShowAddNote: () => void; updateNote: UpdateNote; } @@ -61,6 +63,7 @@ export const NoteCards = React.memo( getNewNoteId, noteIds, showAddNote, + status, toggleShowAddNote, updateNote, }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 4d45b74e9b1b49..15c078e1753553 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -6,7 +6,9 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { isEmpty } from 'lodash/fp'; + +import { TimelineStatus } from '../../../../common/types/timeline'; + import * as i18n from './translations'; import { DeleteTimelines, OpenTimelineResult } from './types'; import { EditTimelineActions } from './export_timeline'; @@ -63,7 +65,7 @@ export const useEditTimelineBatchActions = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { - const isDisabled = isEmpty(selectedItems); + const disabled = selectedItems?.some((item) => item.status === TimelineStatus.immutable); return ( <> , ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ deleteTimelines, isEnableDownloader, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx index d377b10a55c213..b8a7cfd59d2225 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; import { ReactWrapper, mount } from 'enzyme'; -import { useExportTimeline } from '.'; jest.mock('../translations', () => { return { @@ -32,19 +31,6 @@ describe('TimelineDownloader', () => { onComplete: jest.fn(), }; describe('should not render a downloader', () => { - beforeAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ - enableDownloader: false, - setEnableDownloader: jest.fn(), - exportedIds: {}, - getExportedData: jest.fn(), - }); - }); - - afterAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReset(); - }); - test('Without exportedIds', () => { const testProps = { ...defaultTestProps, @@ -65,19 +51,6 @@ describe('TimelineDownloader', () => { }); describe('should render a downloader', () => { - beforeAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ - enableDownloader: false, - setEnableDownloader: jest.fn(), - exportedIds: {}, - getExportedData: jest.fn(), - }); - }); - - afterAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReset(); - }); - test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { const testProps = { ...defaultTestProps, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx index 674cd6dad5f76f..72f149174253aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx @@ -5,31 +5,41 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { useExportTimeline, ExportTimeline } from '.'; +import { shallow } from 'enzyme'; +import { EditTimelineActionsComponent } from '.'; -describe('useExportTimeline', () => { - describe('call with selected timelines', () => { - let exportTimelineRes: ExportTimeline; - const TestHook = () => { - exportTimelineRes = useExportTimeline(); - return
; +describe('EditTimelineActionsComponent', () => { + describe('render', () => { + const props = { + deleteTimelines: jest.fn(), + ids: ['id1'], + isEnableDownloader: false, + isDeleteTimelineModalOpen: false, + onComplete: jest.fn(), + title: 'mockTitle', }; - beforeAll(() => { - mount(); - }); + test('should render timelineDownloader', () => { + const wrapper = shallow(); - test('Downloader should be disabled by default', () => { - expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); + expect(wrapper.find('[data-test-subj="TimelineDownloader"]').exists()).toBeTruthy(); }); - test('Should include disableExportTimelineDownloader in return value', () => { - expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); + test('Should render DeleteTimelineModalOverlay if deleteTimelines is given', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()).toBeTruthy(); }); - test('Should include enableExportTimelineDownloader in return value', () => { - expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); + test('Should not render DeleteTimelineModalOverlay if deleteTimelines is not given', () => { + const newProps = { + ...props, + deleteTimelines: undefined, + }; + const wrapper = shallow(); + expect( + wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists() + ).not.toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx index 7bac3229c81732..2ad4aa9d208cb6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { DeleteTimelines } from '../types'; import { TimelineDownloader } from './export_timeline'; @@ -17,25 +17,7 @@ export interface ExportTimeline { isEnableDownloader: boolean; } -export const useExportTimeline = (): ExportTimeline => { - const [isEnableDownloader, setIsEnableDownloader] = useState(false); - - const enableExportTimelineDownloader = useCallback(() => { - setIsEnableDownloader(true); - }, []); - - const disableExportTimelineDownloader = useCallback(() => { - setIsEnableDownloader(false); - }, []); - - return { - disableExportTimelineDownloader, - enableExportTimelineDownloader, - isEnableDownloader, - }; -}; - -const EditTimelineActionsComponent: React.FC<{ +export const EditTimelineActionsComponent: React.FC<{ deleteTimelines: DeleteTimelines | undefined; ids: string[]; isEnableDownloader: boolean; @@ -52,6 +34,7 @@ const EditTimelineActionsComponent: React.FC<{ }) => ( <> {deleteTimelines != null && ( { } }; +const setTimelineColumn = (col: ColumnHeaderResult) => { + const timelineCols: ColumnHeaderOptions = { + ...col, + columnHeaderType: defaultColumnHeaderType, + id: col.id != null ? col.id : 'unknown', + placeholder: col.placeholder != null ? col.placeholder : undefined, + category: col.category != null ? col.category : undefined, + description: col.description != null ? col.description : undefined, + example: col.example != null ? col.example : undefined, + type: col.type != null ? col.type : undefined, + aggregatable: col.aggregatable != null ? col.aggregatable : undefined, + width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + }; + return timelineCols; +}; + +const setTimelineFilters = (filter: FilterTimelineResult) => ({ + $state: { + store: 'appState', + }, + meta: { + ...filter.meta, + ...(filter.meta && filter.meta.field != null ? { params: parseString(filter.meta.field) } : {}), + ...(filter.meta && filter.meta.params != null + ? { params: parseString(filter.meta.params) } + : {}), + ...(filter.meta && filter.meta.value != null ? { value: parseString(filter.meta.value) } : {}), + }, + ...(filter.exists != null ? { exists: parseString(filter.exists) } : {}), + ...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}), + ...(filter.missing != null ? { exists: parseString(filter.missing) } : {}), + ...(filter.query != null ? { query: parseString(filter.query) } : {}), + ...(filter.range != null ? { range: parseString(filter.range) } : {}), + ...(filter.script != null ? { exists: parseString(filter.script) } : {}), +}); + +const setEventIdToNoteIds = ( + duplicate: boolean, + eventIdToNoteIds: NoteResult[] | null | undefined +) => + duplicate + ? {} + : eventIdToNoteIds != null + ? eventIdToNoteIds.reduce((acc, note) => { + if (note.eventId != null) { + const eventNotes = getOr([], note.eventId, acc); + return { ...acc, [note.eventId]: [...eventNotes, note.noteId] }; + } + return acc; + }, {}) + : {}; + +const setPinnedEventsSaveObject = ( + duplicate: boolean, + pinnedEventsSaveObject: PinnedEvent[] | null | undefined +) => + duplicate + ? {} + : pinnedEventsSaveObject != null + ? pinnedEventsSaveObject.reduce( + (acc, pinnedEvent) => ({ + ...acc, + ...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}), + }), + {} + ) + : {}; + +const setPinnedEventIds = (duplicate: boolean, pinnedEventIds: string[] | null | undefined) => + duplicate + ? {} + : pinnedEventIds != null + ? pinnedEventIds.reduce((acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), {}) + : {}; + +// eslint-disable-next-line complexity export const defaultTimelineToTimelineModel = ( timeline: TimelineResult, duplicate: boolean ): TimelineModel => { - return Object.entries({ + const isTemplate = timeline.timelineType === TimelineType.template; + const timelineEntries = { ...timeline, - columns: - timeline.columns != null - ? timeline.columns.map((col) => { - const timelineCols: ColumnHeaderOptions = { - ...col, - columnHeaderType: defaultColumnHeaderType, - id: col.id != null ? col.id : 'unknown', - placeholder: col.placeholder != null ? col.placeholder : undefined, - category: col.category != null ? col.category : undefined, - description: col.description != null ? col.description : undefined, - example: col.example != null ? col.example : undefined, - type: col.type != null ? col.type : undefined, - aggregatable: col.aggregatable != null ? col.aggregatable : undefined, - width: - col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, - }; - return timelineCols; - }) - : defaultHeaders, - eventIdToNoteIds: duplicate - ? {} - : timeline.eventIdToNoteIds != null - ? timeline.eventIdToNoteIds.reduce((acc, note) => { - if (note.eventId != null) { - const eventNotes = getOr([], note.eventId, acc); - return { ...acc, [note.eventId]: [...eventNotes, note.noteId] }; - } - return acc; - }, {}) - : {}, - filters: - timeline.filters != null - ? timeline.filters.map((filter) => ({ - $state: { - store: 'appState', - }, - meta: { - ...filter.meta, - ...(filter.meta && filter.meta.field != null - ? { params: parseString(filter.meta.field) } - : {}), - ...(filter.meta && filter.meta.params != null - ? { params: parseString(filter.meta.params) } - : {}), - ...(filter.meta && filter.meta.value != null - ? { value: parseString(filter.meta.value) } - : {}), - }, - ...(filter.exists != null ? { exists: parseString(filter.exists) } : {}), - ...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}), - ...(filter.missing != null ? { exists: parseString(filter.missing) } : {}), - ...(filter.query != null ? { query: parseString(filter.query) } : {}), - ...(filter.range != null ? { range: parseString(filter.range) } : {}), - ...(filter.script != null ? { exists: parseString(filter.script) } : {}), - })) - : [], + columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds), + filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [], isFavorite: duplicate ? false : timeline.favorite != null ? timeline.favorite.length > 0 : false, noteIds: duplicate ? [] : timeline.noteIds != null ? timeline.noteIds : [], - pinnedEventIds: duplicate - ? {} - : timeline.pinnedEventIds != null - ? timeline.pinnedEventIds.reduce( - (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), - {} - ) - : {}, - pinnedEventsSaveObject: duplicate - ? {} - : timeline.pinnedEventsSaveObject != null - ? timeline.pinnedEventsSaveObject.reduce( - (acc, pinnedEvent) => ({ - ...acc, - ...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}), - }), - {} - ) - : {}, + pinnedEventIds: setPinnedEventIds(duplicate, timeline.pinnedEventIds), + pinnedEventsSaveObject: setPinnedEventsSaveObject(duplicate, timeline.pinnedEventsSaveObject), id: duplicate ? '' : timeline.savedObjectId, + status: duplicate ? TimelineStatus.active : timeline.status, savedObjectId: duplicate ? null : timeline.savedObjectId, version: duplicate ? null : timeline.version, - title: duplicate ? '' : timeline.title || '', - templateTimelineId: duplicate ? null : timeline.templateTimelineId, - templateTimelineVersion: duplicate ? null : timeline.templateTimelineVersion, - }).reduce((acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), { - ...timelineDefaults, - id: '', - }); + title: duplicate ? `${timeline.title} - Duplicate` : timeline.title || '', + templateTimelineId: duplicate && isTemplate ? uuid.v4() : timeline.templateTimelineId, + templateTimelineVersion: duplicate && isTemplate ? 1 : timeline.templateTimelineVersion, + }; + return Object.entries(timelineEntries).reduce( + (acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), + { + ...timelineDefaults, + id: '', + } + ); }; export const formatTimelineResultToModel = ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 24dee1460810fa..ea63f2b7b0710a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -9,9 +9,9 @@ import React, { useEffect, useState, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; -import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; -import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query'; -import { useGetAllTimeline } from '../../containers/all'; + +import { disableTemplate } from '../../../../common/constants'; + import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types'; import { State } from '../../../common/store'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; @@ -21,6 +21,12 @@ import { createTimeline as dispatchCreateNewTimeline, updateIsLoading as dispatchUpdateIsLoading, } from '../../../timelines/store/timeline/actions'; + +import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query'; +import { useGetAllTimeline } from '../../containers/all'; + +import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; + import { OpenTimeline } from './open_timeline'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; @@ -42,7 +48,7 @@ import { } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { useTimelineTypes } from './use_timeline_types'; -import { disableTemplate } from '../../../../common/constants'; +import { useTimelineStatus } from './use_timeline_status'; interface OwnProps { apolloClient: ApolloClient; @@ -106,28 +112,54 @@ export const StatefulOpenTimelineComponent = React.memo( /** The requested field to sort on */ const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); - const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes(); - const { fetchAllTimeline, timelines, loading, totalCount } = useGetAllTimeline(); - - const refetch = useCallback( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - search, - sort: { - sortField: sortField as SortFieldTimeline, - sortOrder: sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - timelineType, - }), - - // eslint-disable-next-line react-hooks/exhaustive-deps - [pageIndex, pageSize, search, sortField, sortDirection, timelineType, onlyFavorites] - ); + const { + customTemplateTimelineCount, + defaultTimelineCount, + elasticTemplateTimelineCount, + favoriteCount, + fetchAllTimeline, + timelines, + loading, + totalCount, + templateTimelineCount, + } = useGetAllTimeline(); + const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes({ + defaultTimelineCount, + templateTimelineCount, + }); + const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({ + timelineType, + customTemplateTimelineCount, + elasticTemplateTimelineCount, + }); + const refetch = useCallback(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + search, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + timelineType, + templateTimelineType, + status: timelineStatus, + }); + }, [ + fetchAllTimeline, + pageIndex, + pageSize, + search, + sortField, + sortDirection, + timelineType, + timelineStatus, + templateTimelineType, + onlyFavorites, + ]); /** Invoked when the user presses enters to submit the text in the search input */ const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => { @@ -264,6 +296,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + favoriteCount={favoriteCount} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} importDataModalToggle={importDataModalToggle} @@ -285,7 +318,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - tabs={!disableTemplate ? timelineTabs : undefined} + templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + timelineType={timelineType} + timelineFilter={!disableTemplate ? timelineTabs : null} title={title} totalSearchResultsCount={totalCount} /> @@ -294,6 +329,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline-modal'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + favoriteCount={favoriteCount} hideActions={hideActions} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} @@ -312,7 +348,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - tabs={!disableTemplate ? timelineFilters : undefined} + templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + timelineType={timelineType} + timelineFilter={!disableTemplate ? timelineFilters : null} title={title} totalSearchResultsCount={totalCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index a331c62ec47545..f42914c86f46b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -16,6 +16,7 @@ import { TimelinesTableProps } from './timelines_table'; import { mockTimelineResults } from '../../../common/mock/timeline_results'; import { OpenTimeline } from './open_timeline'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants'; +import { TimelineType } from '../../../../common/types/timeline'; jest.mock('../../../common/lib/kibana'); @@ -46,8 +47,9 @@ describe('OpenTimeline', () => { selectedItems: [], sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, - tabs:
, title, + timelineType: TimelineType.default, + templateTimelineFilter: [
], totalSearchResultsCount: mockSearchResults.length, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 4894b1b2577a9b..849143894efe0b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -4,17 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel, EuiBasicTable } from '@elastic/eui'; +import { EuiPanel, EuiBasicTable, EuiCallOut, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; -import { SearchRow } from './search_row'; -import { TimelinesTable } from './timelines_table'; -import { ImportDataModal } from '../../../common/components/import_data_modal'; -import * as i18n from './translations'; -import { importTimelines } from '../../containers/api'; +import { ImportDataModal } from '../../../common/components/import_data_modal'; import { UtilityBarGroup, UtilityBarText, @@ -22,14 +16,23 @@ import { UtilityBarSection, UtilityBarAction, } from '../../../common/components/utility_bar'; + +import { importTimelines } from '../../containers/api'; + import { useEditTimelineBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; import { EditOneTimelineAction } from './export_timeline'; +import { SearchRow } from './search_row'; +import { TimelinesTable } from './timelines_table'; +import * as i18n from './translations'; +import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; +import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; export const OpenTimeline = React.memo( ({ deleteTimelines, defaultPageSize, + favoriteCount, isLoading, itemIdToExpandedNotesRowMap, importDataModalToggle, @@ -51,11 +54,12 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, - tabs, + timelineType, + timelineFilter, + templateTimelineFilter, totalSearchResultsCount, }) => { const tableRef = useRef>(); - const { actionItem, enableExportTimelineDownloader, @@ -124,6 +128,8 @@ export const OpenTimeline = React.memo( [onDeleteSelected, deleteTimelines] ); + const SearchRowContent = useMemo(() => <>{templateTimelineFilter}, [templateTimelineFilter]); + return ( <> ( /> - {!!tabs && tabs} + + + {!!timelineFilter && timelineFilter} + > + {SearchRowContent} + @@ -206,6 +217,7 @@ export const OpenTimeline = React.memo( showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} + timelineType={timelineType} tableRef={tableRef} totalSearchResultsCount={totalSearchResultsCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 42a3f9a44d4b6f..1d08f0296ce0de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -16,6 +16,7 @@ import { TimelinesTableProps } from '../timelines_table'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineModalBody } from './open_timeline_modal_body'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { TimelineType } from '../../../../../common/types/timeline'; jest.mock('../../../../common/lib/kibana'); @@ -45,7 +46,8 @@ describe('OpenTimelineModal', () => { selectedItems: [], sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, - tabs:
, + timelineType: TimelineType.default, + templateTimelineFilter: [
], title, totalSearchResultsCount: mockSearchResults.length, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 9eab64d6fcf525..bf66d9a52ff2ff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -23,6 +23,7 @@ export const OpenTimelineModalBody = memo( ({ deleteTimelines, defaultPageSize, + favoriteCount, hideActions = [], isLoading, itemIdToExpandedNotesRowMap, @@ -42,7 +43,9 @@ export const OpenTimelineModalBody = memo( selectedItems, sortDirection, sortField, - tabs, + timelineFilter, + timelineType, + templateTimelineFilter, title, totalSearchResultsCount, }) => { @@ -54,6 +57,16 @@ export const OpenTimelineModalBody = memo( return actions.filter((action) => !hideActions.includes(action)); }, [onDeleteSelected, deleteTimelines, hideActions]); + const SearchRowContent = useMemo( + () => ( + <> + {!!timelineFilter && timelineFilter} + {!!templateTimelineFilter && templateTimelineFilter} + + ), + [timelineFilter, templateTimelineFilter] + ); + return ( <> @@ -67,13 +80,15 @@ export const OpenTimelineModalBody = memo( <> + > + {SearchRowContent} + @@ -96,6 +111,7 @@ export const OpenTimelineModalBody = memo( showExtendedColumns={false} sortDirection={sortDirection} sortField={sortField} + timelineType={timelineType} totalSearchResultsCount={totalSearchResultsCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx index 557649aa3aa43f..6f9178664ccf0a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx @@ -34,8 +34,13 @@ SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; type Props = Pick< OpenTimelineProps, - 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' -> & { tabs?: JSX.Element }; + | 'favoriteCount' + | 'onlyFavorites' + | 'onQueryChange' + | 'onToggleOnlyFavorites' + | 'query' + | 'totalSearchResultsCount' +> & { children?: JSX.Element | null }; const searchBox = { placeholder: i18n.SEARCH_PLACEHOLDER, @@ -47,12 +52,13 @@ const searchBox = { */ export const SearchRow = React.memo( ({ + favoriteCount, onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount, - tabs, + children, }) => { return ( @@ -68,10 +74,11 @@ export const SearchRow = React.memo( data-test-subj="only-favorites-toggle" hasActiveFilters={onlyFavorites} onClick={onToggleOnlyFavorites} + numFilters={favoriteCount ?? undefined} > {i18n.ONLY_FAVORITES} - {tabs} + {!!children && children} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index c92e241c0fe799..5b8eb8fd0365c4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -16,6 +16,7 @@ import { TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; +import { TimelineStatus } from '../../../../../common/types/timeline'; /** * Returns the action columns (e.g. delete, open duplicate timeline) @@ -54,7 +55,9 @@ export const getActionsColumns = ({ onClick: (selectedTimeline: OpenTimelineResult) => { if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); }, - enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + enabled: (timeline: OpenTimelineResult) => { + return timeline.savedObjectId != null && timeline.status !== TimelineStatus.immutable; + }, description: i18n.EXPORT_SELECTED, 'data-test-subj': 'export-timeline', }; @@ -65,7 +68,8 @@ export const getActionsColumns = ({ onClick: (selectedTimeline: OpenTimelineResult) => { if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); }, - enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + enabled: ({ savedObjectId, status }: OpenTimelineResult) => + savedObjectId != null && status !== TimelineStatus.immutable, description: i18n.DELETE_SELECTED, 'data-test-subj': 'delete-timeline', }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx index 5b0f3ded7d71bd..e07c6b6b461491 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -13,55 +13,68 @@ import { ACTION_COLUMN_WIDTH } from './common_styles'; import { getNotesCount, getPinnedEventCount } from '../helpers'; import * as i18n from '../translations'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; +import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../../common/types/timeline'; /** * Returns the columns that have icon headers */ -export const getIconHeaderColumns = () => [ - { - align: 'center', - field: 'pinnedEventIds', - name: ( - - - - ), - render: (_: Record | null | undefined, timelineResult: OpenTimelineResult) => ( - {`${getPinnedEventCount(timelineResult)}`} - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, - { - align: 'center', - field: 'eventIdToNoteIds', - name: ( - - - - ), - render: ( - _: Record | null | undefined, - timelineResult: OpenTimelineResult - ) => {getNotesCount(timelineResult)}, - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, - { - align: 'center', - field: 'favorite', - name: ( - - - - ), - render: (favorite: FavoriteTimelineResult[] | null | undefined) => { - const isFavorite = favorite != null && favorite.length > 0; - const fill = isFavorite ? 'starFilled' : 'starEmpty'; +export const getIconHeaderColumns = ({ + timelineType, +}: { + timelineType: TimelineTypeLiteralWithNull; +}) => { + const columns = { + note: { + align: 'center', + field: 'eventIdToNoteIds', + name: ( + + + + ), + render: ( + _: Record | null | undefined, + timelineResult: OpenTimelineResult + ) => {getNotesCount(timelineResult)}, + sortable: false, + width: ACTION_COLUMN_WIDTH, + }, + pinnedEvent: { + align: 'center', + field: 'pinnedEventIds', + name: ( + + + + ), + render: ( + _: Record | null | undefined, + timelineResult: OpenTimelineResult + ) => ( + {`${getPinnedEventCount(timelineResult)}`} + ), + sortable: false, + width: ACTION_COLUMN_WIDTH, + }, + favorite: { + align: 'center', + field: 'favorite', + name: ( + + + + ), + render: (favorite: FavoriteTimelineResult[] | null | undefined) => { + const isFavorite = favorite != null && favorite.length > 0; + const fill = isFavorite ? 'starFilled' : 'starEmpty'; - return ; + return ; + }, + sortable: false, + width: ACTION_COLUMN_WIDTH, }, - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, -]; + }; + const templateColumns = [columns.note, columns.favorite]; + const defaultColumns = [columns.pinnedEvent, columns.note, columns.favorite]; + return timelineType === TimelineType.template ? templateColumns : defaultColumns; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 7091ef1f0a1f9c..fdba3247afb38f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -24,6 +24,7 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; +import { TimelineTypeLiteralWithNull } from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -58,6 +59,7 @@ export const getTimelinesTableColumns = ({ onOpenTimeline, onToggleShowNotes, showExtendedColumns, + timelineType, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; @@ -68,6 +70,7 @@ export const getTimelinesTableColumns = ({ onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; + timelineType: TimelineTypeLiteralWithNull; }) => { return [ ...getCommonColumns({ @@ -76,7 +79,7 @@ export const getTimelinesTableColumns = ({ onToggleShowNotes, }), ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(), + ...getIconHeaderColumns({ timelineType }), ...getActionsColumns({ actionTimelineToShow, deleteTimelines, @@ -105,6 +108,7 @@ export interface TimelinesTableProps { showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; + timelineType: TimelineTypeLiteralWithNull; // eslint-disable-next-line @typescript-eslint/no-explicit-any tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; totalSearchResultsCount: number; @@ -134,6 +138,7 @@ export const TimelinesTable = React.memo( sortField, sortDirection, tableRef, + timelineType, totalSearchResultsCount, }) => { const pagination = { @@ -174,6 +179,7 @@ export const TimelinesTable = React.memo( onSelectionChange, onToggleShowNotes, showExtendedColumns, + timelineType, })} compressed data-test-subj="timelines-table" diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts index 78ca898cc407e8..0770f460794a6a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts @@ -7,6 +7,7 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines_page'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; import { OpenTimelineResult } from '../types'; import { TimelinesTableProps } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; export const getMockTimelinesTableProps = ( mockOpenTimelineResults: OpenTimelineResult[] @@ -28,5 +29,6 @@ export const getMockTimelinesTableProps = ( showExtendedColumns: true, sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, + timelineType: TimelineType.default, totalSearchResultsCount: mockOpenTimelineResults.length, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index edd77330f50841..7b07548af67ae3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -220,6 +220,20 @@ export const TAB_TEMPLATES = i18n.translate( } ); +export const FILTER_ELASTIC_TIMELINES = i18n.translate( + 'xpack.securitySolution.timelines.components.templateFilter.elasticTitle', + { + defaultMessage: 'Elastic templates', + } +); + +export const FILTER_CUSTOM_TIMELINES = i18n.translate( + 'xpack.securitySolution.timelines.components.templateFilter.customizedTitle', + { + defaultMessage: 'Custom templates', + } +); + export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', { @@ -230,7 +244,7 @@ export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( export const SELECT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', { - defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', + defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import', } ); @@ -280,3 +294,10 @@ export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', } ); + +export const TEMPLATE_CALL_OUT_MESSAGE = i18n.translate( + 'xpack.securitySolution.timelines.components.templateCallOutMessageTitle', + { + defaultMessage: 'Now you can add timeline templates and link it to rules.', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index e1515a3a79254d..8811d5452e0396 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -8,7 +8,12 @@ import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../../graphql/types'; -import { TimelineTypeLiteral } from '../../../../common/types/timeline'; +import { + TimelineTypeLiteral, + TimelineTypeLiteralWithNull, + TimelineStatus, + TemplateTimelineTypeLiteral, +} from '../../../../common/types/timeline'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -46,6 +51,7 @@ export interface OpenTimelineResult { notes?: TimelineResultNote[] | null; pinnedEventIds?: Readonly> | null; savedObjectId?: string | null; + status?: TimelineStatus | null; title?: string | null; templateTimelineId?: string | null; type?: TimelineTypeLiteral; @@ -118,6 +124,8 @@ export interface OpenTimelineProps { deleteTimelines?: DeleteTimelines; /** The default requested size of each page of search results */ defaultPageSize: number; + /** The number of favorite timeline*/ + favoriteCount?: number | null | undefined; /** Displays an indicator that data is loading when true */ isLoading: boolean; /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ @@ -160,8 +168,12 @@ export interface OpenTimelineProps { sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ sortField: string; + /** this affects timeline's behaviour like editable / duplicatible */ + timelineType: TimelineTypeLiteralWithNull; + /** when timelineType === template, templatetimelineFilter is a JSX.Element */ + templateTimelineFilter: JSX.Element[] | null; /** timeline / template timeline */ - tabs?: JSX.Element; + timelineFilter?: JSX.Element | JSX.Element[] | null; /** The title of the Open Timeline component */ title: string; /** The total (server-side) count of the search results */ @@ -196,9 +208,19 @@ export enum TimelineTabsStyle { } export interface TimelineTab { - id: TimelineTypeLiteral; - name: string; + count: number | undefined; disabled: boolean; href: string; + id: TimelineTypeLiteral; + name: string; onClick: (ev: { preventDefault: () => void }) => void; + withNext: boolean; +} + +export interface TemplateTimelineFilter { + id: TemplateTimelineTypeLiteral; + name: string; + disabled: boolean; + withNext: boolean; + count: number | undefined; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx new file mode 100644 index 00000000000000..f17f6aebaddf6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiFilterButton } from '@elastic/eui'; + +import { + TimelineStatus, + TimelineType, + TimelineTypeLiteralWithNull, + TemplateTimelineType, + TemplateTimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, +} from '../../../../common/types/timeline'; + +import * as i18n from './translations'; +import { TemplateTimelineFilter } from './types'; +import { disableTemplate } from '../../../../common/constants'; + +export const useTimelineStatus = ({ + timelineType, + elasticTemplateTimelineCount, + customTemplateTimelineCount, +}: { + timelineType: TimelineTypeLiteralWithNull; + elasticTemplateTimelineCount?: number | null; + customTemplateTimelineCount?: number | null; +}): { + timelineStatus: TimelineStatusLiteralWithNull; + templateTimelineType: TemplateTimelineTypeLiteralWithNull; + templateTimelineFilter: JSX.Element[] | null; +} => { + const [selectedTab, setSelectedTab] = useState( + disableTemplate ? null : TemplateTimelineType.elastic + ); + const isTemplateFilterEnabled = useMemo(() => timelineType === TimelineType.template, [ + timelineType, + ]); + + const templateTimelineType = useMemo( + () => (disableTemplate || !isTemplateFilterEnabled ? null : selectedTab), + [selectedTab, isTemplateFilterEnabled] + ); + + const timelineStatus = useMemo( + () => + templateTimelineType == null + ? null + : templateTimelineType === TemplateTimelineType.elastic + ? TimelineStatus.immutable + : TimelineStatus.active, + [templateTimelineType] + ); + + const filters = useMemo( + () => [ + { + id: TemplateTimelineType.elastic, + name: i18n.FILTER_ELASTIC_TIMELINES, + disabled: !isTemplateFilterEnabled, + withNext: true, + count: elasticTemplateTimelineCount ?? undefined, + }, + { + id: TemplateTimelineType.custom, + name: i18n.FILTER_CUSTOM_TIMELINES, + disabled: !isTemplateFilterEnabled, + withNext: false, + count: customTemplateTimelineCount ?? undefined, + }, + ], + [customTemplateTimelineCount, elasticTemplateTimelineCount, isTemplateFilterEnabled] + ); + + const onFilterClicked = useCallback( + (tabId) => { + if (selectedTab === tabId) { + setSelectedTab(null); + } else { + setSelectedTab(tabId); + } + }, + [setSelectedTab, selectedTab] + ); + + const templateTimelineFilter = useMemo(() => { + return isTemplateFilterEnabled + ? filters.map((tab: TemplateTimelineFilter) => ( + + {tab.name} + + )) + : null; + }, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]); + + return { + timelineStatus, + templateTimelineType, + templateTimelineFilter, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 56c67b0c294a22..bee94db3488724 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -13,10 +13,16 @@ import { getTimelineTabsUrl, useFormatUrl } from '../../../common/components/lin import * as i18n from './translations'; import { TimelineTabsStyle, TimelineTab } from './types'; -export const useTimelineTypes = (): { +export const useTimelineTypes = ({ + defaultTimelineCount, + templateTimelineCount, +}: { + defaultTimelineCount?: number | null; + templateTimelineCount?: number | null; +}): { timelineType: TimelineTypeLiteralWithNull; timelineTabs: JSX.Element; - timelineFilters: JSX.Element; + timelineFilters: JSX.Element[]; } => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines); @@ -40,35 +46,52 @@ export const useTimelineTypes = (): { }, [history, urlSearch] ); - - const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = ( - timelineTabsStyle: TimelineTabsStyle - ) => [ - { - id: TimelineType.default, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) - : i18n.TAB_TIMELINES, - href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), - disabled: false, - onClick: goToTimeline, - }, - { - id: TimelineType.template, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) - : i18n.TAB_TEMPLATES, - href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), - disabled: false, - onClick: goToTemplateTimeline, - }, - ]; + const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback( + (timelineTabsStyle: TimelineTabsStyle) => [ + { + id: TimelineType.default, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) + : i18n.TAB_TIMELINES, + href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), + disabled: false, + withNext: true, + count: + timelineTabsStyle === TimelineTabsStyle.filter + ? defaultTimelineCount ?? undefined + : undefined, + onClick: goToTimeline, + }, + { + id: TimelineType.template, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) + : i18n.TAB_TEMPLATES, + href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), + disabled: false, + withNext: false, + count: + timelineTabsStyle === TimelineTabsStyle.filter + ? templateTimelineCount ?? undefined + : undefined, + onClick: goToTemplateTimeline, + }, + ], + [ + defaultTimelineCount, + templateTimelineCount, + urlSearch, + formatUrl, + goToTimeline, + goToTemplateTimeline, + ] + ); const onFilterClicked = useCallback( - (timelineTabsStyle, tabId) => { - if (timelineTabsStyle === TimelineTabsStyle.filter && tabId === timelineType) { + (tabId) => { + if (tabId === timelineType) { setTimelineTypes(null); } else { setTimelineTypes(tabId); @@ -89,7 +112,7 @@ export const useTimelineTypes = (): { href={tab.href} onClick={(ev) => { tab.onClick(ev); - onFilterClicked(TimelineTabsStyle.tab, tab.id); + onFilterClicked(tab.id); }} > {tab.name} @@ -103,24 +126,21 @@ export const useTimelineTypes = (): { }, [tabName]); const timelineFilters = useMemo(() => { - return ( - <> - {getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( - void }) => { - tab.onClick(ev); - onFilterClicked.bind(null, TimelineTabsStyle.filter, tab.id); - }} - > - {tab.name} - - ))} - - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timelineType]); + return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => ( + void }) => { + tab.onClick(ev); + onFilterClicked(tab.id); + }} + withNext={tab.withNext} + > + {tab.name} + + )); + }, [timelineType, getFilterOrTabs, onFilterClicked]); return { timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 92782252719303..012cfd66317de7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -926,6 +926,7 @@ In other use cases the message field can be used to concatenate different values } } start={1521830963132} + status="active" toggleColumn={[MockFunction]} usersViewing={ Array [ 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 a50e7e56661f22..53b018fb00adf6 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 @@ -5,13 +5,24 @@ */ import { mount } from 'enzyme'; import React from 'react'; +import { useSelector } from 'react-redux'; -import { TestProviders } from '../../../../../common/mock'; +import { TestProviders, mockTimelineModel } from '../../../../../common/mock'; import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; import { Actions } from '.'; +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useSelector: jest.fn(), + }; +}); + describe('Actions', () => { + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => { const wrapper = mount( 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 b478070b315783..d343c3db04da6e 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 @@ -3,10 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { Note } from '../../../../../common/lib/note'; +import { StoreState } from '../../../../../common/store/types'; + +import { TimelineModel } from '../../../../store/timeline/model'; + import { AssociateNote, UpdateNote } from '../../../notes/helpers'; import { Pin } from '../../pin'; import { NotesButton } from '../../properties/helpers'; @@ -79,92 +84,101 @@ export const Actions = React.memo( showNotes, toggleShowNotes, updateNote, - }) => ( - - {showCheckboxes && ( - + }) => { + const timeline = useSelector((state) => { + return state.timeline.timelineById['timeline-1']; + }); + return ( + + {showCheckboxes && ( + + + {loadingEventIds.includes(eventId) ? ( + + ) : ( + ) => { + onRowSelected({ + eventIds: [eventId], + isSelected: event.currentTarget.checked, + }); + }} + /> + )} + + + )} + + - {loadingEventIds.includes(eventId) ? ( - - ) : ( - } + + {!loading && ( + ) => { - onRowSelected({ - eventIds: [eventId], - isSelected: event.currentTarget.checked, - }); - }} + onClick={onEventToggled} /> )} - )} - - - {loading && } + <>{additionalActions} - {!loading && ( - - )} - - + {!isEventViewer && ( + <> + + + + + + + - <>{additionalActions} - - {!isEventViewer && ( - <> - - - - + + - - - - - - - - - - - )} - - ), + + + + )} + + ); + }, (nextProps, prevProps) => { return ( prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index cf76cd3ddb8d42..d2175c728aa2a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useSelector } from 'react-redux'; import uuid from 'uuid'; import VisibilitySensor from 'react-visibility-sensor'; @@ -13,7 +14,7 @@ import { TimelineDetailsQuery } from '../../../../containers/details'; import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types'; import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler'; import { Note } from '../../../../../common/lib/note'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { SkeletonRow } from '../../skeleton_row'; import { @@ -33,6 +34,7 @@ import { getEventType } from '../helpers'; import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; +import { StoreState } from '../../../../../common/store'; interface Props { actionsColumnWidth: number; @@ -128,7 +130,9 @@ const StatefulEventComponent: React.FC = ({ const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); const [initialRender, setInitialRender] = useState(false); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - + const timeline = useSelector((state) => { + return state.timeline.timelineById['timeline-1']; + }); const divElement = useRef(null); const onToggleShowNotes = useCallback(() => { @@ -251,6 +255,7 @@ const StatefulEventComponent: React.FC = ({ getNotesByIds={getNotesByIds} noteIds={eventIdToNoteIds[event._id] || emptyNotes} showAddNote={!!showNotes[event._id]} + status={timeline.status} toggleShowAddNote={onToggleShowNotes} updateNote={updateNote} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index e237e99df9ada4..7ecd7ec5ed35c7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -7,6 +7,7 @@ import { Ecs } from '../../../../graphql/types'; import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers'; +import { TimelineType } from '../../../../../common/types/timeline'; describe('helpers', () => { describe('stringifyEvent', () => { @@ -192,21 +193,37 @@ describe('helpers', () => { describe('getPinTooltip', () => { test('it indicates the event may NOT be unpinned when `isPinned` is `true` and the event has notes', () => { - expect(getPinTooltip({ isPinned: true, eventHasNotes: true })).toEqual( - 'This event cannot be unpinned because it has notes' - ); + expect( + getPinTooltip({ isPinned: true, eventHasNotes: true, timelineType: TimelineType.default }) + ).toEqual('This event cannot be unpinned because it has notes'); }); test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => { - expect(getPinTooltip({ isPinned: true, eventHasNotes: false })).toEqual('Pinned event'); + expect( + getPinTooltip({ isPinned: true, eventHasNotes: false, timelineType: TimelineType.default }) + ).toEqual('Pinned event'); }); test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => { - expect(getPinTooltip({ isPinned: false, eventHasNotes: true })).toEqual('Unpinned event'); + expect( + getPinTooltip({ isPinned: false, eventHasNotes: true, timelineType: TimelineType.default }) + ).toEqual('Unpinned event'); }); test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => { - expect(getPinTooltip({ isPinned: false, eventHasNotes: false })).toEqual('Unpinned event'); + expect( + getPinTooltip({ isPinned: false, eventHasNotes: false, timelineType: TimelineType.default }) + ).toEqual('Unpinned event'); + }); + + test('it indicates the event is disabled if timelineType is template', () => { + expect( + getPinTooltip({ + isPinned: false, + eventHasNotes: false, + timelineType: TimelineType.template, + }) + ).toEqual('This event cannot be pinned because it is filtered by a timeline template'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index bdc8c66ec3aa65..52bbccbba58e70 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -15,6 +15,7 @@ import { OnPinEvent, OnUnPinEvent } from '../events'; import { TimelineRowAction, TimelineRowActionOnClick } from './actions'; import * as i18n from './translations'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => @@ -28,10 +29,19 @@ export const getPinTooltip = ({ isPinned, // eslint-disable-next-line no-shadow eventHasNotes, + timelineType, }: { isPinned: boolean; eventHasNotes: boolean; -}) => (isPinned && eventHasNotes ? i18n.PINNED_WITH_NOTES : isPinned ? i18n.PINNED : i18n.UNPINNED); + timelineType: TimelineTypeLiteral; +}) => + timelineType === TimelineType.template + ? i18n.DISABLE_PIN + : isPinned && eventHasNotes + ? i18n.PINNED_WITH_NOTES + : isPinned + ? i18n.PINNED + : i18n.UNPINNED; export interface IsPinnedParams { eventId: string; 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 9b96e0c49c73d7..51bf883ed2d611 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 @@ -5,10 +5,11 @@ */ import React from 'react'; +import { useSelector } from 'react-redux'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { Direction } from '../../../../graphql/types'; -import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; +import { defaultHeaders, mockTimelineData, mockTimelineModel } from '../../../../common/mock'; import { TestProviders } from '../../../../common/mock/test_providers'; import { Body, BodyProps } from '.'; @@ -24,6 +25,13 @@ const mockSort: Sort = { sortDirection: Direction.desc, }; +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useSelector: jest.fn(), + }; +}); jest.mock('../../../../common/components/link_to'); jest.mock( @@ -41,41 +49,43 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({ describe('Body', () => { const mount = useMountAppended(); + const props: BodyProps = { + addNoteToEvent: jest.fn(), + browserFields: mockBrowserFields, + columnHeaders: defaultHeaders, + columnRenderers, + data: mockTimelineData, + eventIdToNoteIds: {}, + height: testBodyHeight, + id: 'timeline-test', + isSelectAllChecked: false, + getNotesByIds: mockGetNotesByIds, + loadingEventIds: [], + onColumnRemoved: jest.fn(), + onColumnResized: jest.fn(), + onColumnSorted: jest.fn(), + onFilterChange: jest.fn(), + onPinEvent: jest.fn(), + onRowSelected: jest.fn(), + onSelectAll: jest.fn(), + onUnPinEvent: jest.fn(), + onUpdateColumns: jest.fn(), + pinnedEventIds: {}, + rowRenderers, + selectedEventIds: {}, + show: true, + sort: mockSort, + showCheckboxes: false, + toggleColumn: jest.fn(), + updateNote: jest.fn(), + }; + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); describe('rendering', () => { test('it renders the column headers', () => { const wrapper = mount( - + ); @@ -85,36 +95,7 @@ describe('Body', () => { test('it renders the scroll container', () => { const wrapper = mount( - + ); @@ -124,36 +105,7 @@ describe('Body', () => { test('it renders events', () => { const wrapper = mount( - + ); @@ -162,39 +114,10 @@ describe('Body', () => { test('it renders a tooltip for timestamp', async () => { const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); - + const testProps = { ...props, columnHeaders: headersJustTimestamp }; const wrapper = mount( - + ); wrapper.update(); @@ -215,6 +138,11 @@ describe('Body', () => { describe('action on event', () => { const dispatchAddNoteToEvent = jest.fn(); const dispatchOnPinEvent = jest.fn(); + const testProps = { + ...props, + addNoteToEvent: dispatchAddNoteToEvent, + onPinEvent: dispatchOnPinEvent, + }; const addaNoteToEvent = (wrapper: ReturnType, note: string) => { wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click'); @@ -251,36 +179,7 @@ describe('Body', () => { test('Add a Note to an event', () => { const wrapper = mount( - + ); addaNoteToEvent(wrapper, 'hello world'); @@ -290,44 +189,13 @@ describe('Body', () => { }); test('Add two Note to an event', () => { - const Proxy = (props: BodyProps) => ( + const Proxy = (proxyProps: BodyProps) => ( - + ); - const wrapper = mount( - - ); + const wrapper = mount(); addaNoteToEvent(wrapper, 'hello world'); dispatchAddNoteToEvent.mockClear(); dispatchOnPinEvent.mockClear(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index 63b92d6b316cc8..ef7ee26cd3ecf5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -13,6 +13,13 @@ export const NOTES_TOOLTIP = i18n.translate( } ); +export const NOTES_DISABLE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.timeline.body.notes.disableEventTooltip', + { + defaultMessage: 'Add notes for event filtered by a timeline template is not allowed', + } +); + export const COPY_TO_CLIPBOARD = i18n.translate( 'xpack.securitySolution.timeline.body.copyToClipboardButtonLabel', { @@ -38,6 +45,13 @@ export const PINNED_WITH_NOTES = i18n.translate( } ); +export const DISABLE_PIN = i18n.translate( + 'xpack.securitySolution.timeline.body.pinning.disablePinnnedTooltip', + { + defaultMessage: 'This event cannot be pinned because it is filtered by a timeline template', + } +); + export const EXPAND = i18n.translate( 'xpack.securitySolution.timeline.body.actions.expandAriaLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index 6fb2443486f811..922148535d1265 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -15,6 +15,7 @@ import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { TimelineHeader } from '.'; +import { TimelineStatus } from '../../../../../common/types/timeline'; const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; @@ -23,43 +24,32 @@ jest.mock('../../../../common/lib/kibana'); describe('Header', () => { const indexPattern = mockIndexPattern; const mount = useMountAppended(); + const props = { + browserFields: {}, + dataProviders: mockDataProviders, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + id: 'foo', + indexPattern, + onDataProviderEdited: jest.fn(), + onDataProviderRemoved: jest.fn(), + onToggleDataProviderEnabled: jest.fn(), + onToggleDataProviderExcluded: jest.fn(), + show: true, + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.active, + }; describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the data providers when show is true', () => { + const testProps = { ...props, show: true }; const wrapper = mount( - + ); @@ -67,21 +57,11 @@ describe('Header', () => { }); test('it does NOT render the data providers when show is false', () => { + const testProps = { ...props, show: false }; + const wrapper = mount( - + ); @@ -89,21 +69,15 @@ describe('Header', () => { }); test('it renders the unauthorized call out providers', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index e8f1e73719234d..0541dee4b1e52e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -22,6 +22,10 @@ import { StatefulSearchOrFilter } from '../search_or_filter'; import { BrowserFields } from '../../../../common/containers/source'; import * as i18n from './translations'; +import { + TimelineStatus, + TimelineStatusLiteralWithNull, +} from '../../../../../common/types/timeline'; interface Props { browserFields: BrowserFields; @@ -36,6 +40,7 @@ interface Props { onToggleDataProviderExcluded: OnToggleDataProviderExcluded; show: boolean; showCallOutUnauthorizedMsg: boolean; + status: TimelineStatusLiteralWithNull; } const TimelineHeaderComponent: React.FC = ({ @@ -51,6 +56,7 @@ const TimelineHeaderComponent: React.FC = ({ onToggleDataProviderExcluded, show, showCallOutUnauthorizedMsg, + status, }) => ( <> {showCallOutUnauthorizedMsg && ( @@ -62,7 +68,15 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - + {status === TimelineStatus.immutable && ( + + )} {show && !showGraphView(graphEventId) && ( <> { const originalModule = jest.requireActual('../../../common/lib/kibana'); @@ -88,6 +89,8 @@ describe('StatefulTimeline', () => { showCallOutUnauthorizedMsg: false, sort, start: startDate, + status: TimelineStatus.active, + timelineType: TimelineType.default, updateColumns: timelineActions.updateColumns, updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, 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 a66c01d0b5d0b9..35622eddc359c0 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 @@ -57,6 +57,8 @@ const StatefulTimelineComponent = React.memo( showCallOutUnauthorizedMsg, sort, start, + status, + timelineType, updateDataProviderEnabled, updateDataProviderExcluded, updateItemsPerPage, @@ -189,6 +191,7 @@ const StatefulTimelineComponent = React.memo( showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} sort={sort!} start={start} + status={status} toggleColumn={toggleColumn} usersViewing={usersViewing} /> @@ -207,6 +210,8 @@ const StatefulTimelineComponent = React.memo( prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && prevProps.start === nextProps.start && + prevProps.timelineType === nextProps.timelineType && + prevProps.status === nextProps.status && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && deepEqual(prevProps.filters, nextProps.filters) && @@ -238,11 +243,12 @@ const makeMapStateToProps = () => { kqlMode, show, sort, + status, + timelineType, } = timeline; const kqlQueryExpression = getKqlQueryTimeline(state, id)!; const timelineFilter = kqlMode === 'filter' ? filters || [] : []; - return { columns, dataProviders, @@ -261,6 +267,8 @@ const makeMapStateToProps = () => { showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), sort, start: input.timerange.from, + status, + timelineType, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx index 800ea814fdd509..30fe8ae0ca1f66 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx @@ -8,6 +8,8 @@ import { EuiButtonIcon, IconSize } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React from 'react'; +import { TimelineType, TimelineTypeLiteral } from '../../../../../common/types/timeline'; + import * as i18n from '../body/translations'; export type PinIcon = 'pin' | 'pinFilled'; @@ -17,21 +19,25 @@ export const getPinIcon = (pinned: boolean): PinIcon => (pinned ? 'pinFilled' : interface Props { allowUnpinning: boolean; iconSize?: IconSize; + timelineType?: TimelineTypeLiteral; onClick?: () => void; pinned: boolean; } export const Pin = React.memo( - ({ allowUnpinning, iconSize = 'm', onClick = noop, pinned }) => ( - - ) + ({ allowUnpinning, iconSize = 'm', onClick = noop, pinned, timelineType }) => { + const isTemplate = timelineType === TimelineType.template; + return ( + + ); + } ); Pin.displayName = 'Pin'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 528af23191ee9b..21140d668d7167 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -27,6 +27,7 @@ import { TimelineTypeLiteral, TimelineStatus, TimelineType, + TimelineStatusLiteral, TimelineId, } from '../../../../../common/types/timeline'; import { SecurityPageName } from '../../../../app/types'; @@ -262,11 +263,13 @@ interface NotesButtonProps { getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; size: 's' | 'l'; + status: TimelineStatusLiteral; showNotes: boolean; toggleShowNotes: () => void; text?: string; toolTip?: string; updateNote: UpdateNote; + timelineType: TimelineTypeLiteral; } const getNewNoteId = (): string => uuid.v4(); @@ -303,16 +306,24 @@ LargeNotesButton.displayName = 'LargeNotesButton'; interface SmallNotesButtonProps { noteIds: string[]; toggleShowNotes: () => void; + timelineType: TimelineTypeLiteral; } -const SmallNotesButton = React.memo(({ noteIds, toggleShowNotes }) => ( - toggleShowNotes()} - /> -)); +const SmallNotesButton = React.memo( + ({ noteIds, toggleShowNotes, timelineType }) => { + const isTemplate = timelineType === TimelineType.template; + + return ( + toggleShowNotes()} + isDisabled={isTemplate} + /> + ); + } +); SmallNotesButton.displayName = 'SmallNotesButton'; /** @@ -326,25 +337,32 @@ const NotesButtonComponent = React.memo( noteIds, showNotes, size, + status, toggleShowNotes, text, updateNote, + timelineType, }) => ( <> {size === 'l' ? ( ) : ( - + )} {size === 'l' && showNotes ? ( @@ -364,6 +382,8 @@ export const NotesButton = React.memo( noteIds, showNotes, size, + status, + timelineType, toggleShowNotes, toolTip, text, @@ -377,9 +397,11 @@ export const NotesButton = React.memo( noteIds={noteIds} showNotes={showNotes} size={size} + status={status} toggleShowNotes={toggleShowNotes} text={text} updateNote={updateNote} + timelineType={timelineType} /> ) : ( @@ -390,9 +412,11 @@ export const NotesButton = React.memo( noteIds={noteIds} showNotes={showNotes} size={size} + status={status} toggleShowNotes={toggleShowNotes} text={text} updateNote={updateNote} + timelineType={timelineType} /> ) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 1b76db409484f4..cd089d10d5d4cb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -6,13 +6,14 @@ import { mount } from 'enzyme'; import React from 'react'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, createSecuritySolutionStorageMock, TestProviders, + kibanaObservable, } from '../../../../common/mock'; import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; @@ -86,6 +87,7 @@ const defaultProps = { isDatepickerLocked: false, isFavorite: false, title: '', + timelineType: TimelineType.default, description: '', getNotesByIds: jest.fn(), noteIds: [], @@ -103,11 +105,23 @@ describe('Properties', () => { const { storage } = createSecuritySolutionStorageMock(); let mockedWidth = 1000; - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { jest.clearAllMocks(); - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth }); }); @@ -130,9 +144,10 @@ describe('Properties', () => { }); test('renders correctly draft timeline', () => { + const testProps = { ...defaultProps, status: TimelineStatus.draft }; const wrapper = mount( - + ); @@ -157,9 +172,11 @@ describe('Properties', () => { }); test('it renders a filled star icon when it is a favorite', () => { + const testProps = { ...defaultProps, isFavorite: true }; + const wrapper = mount( - + ); @@ -168,10 +185,10 @@ describe('Properties', () => { test('it renders the title of the timeline', () => { const title = 'foozle'; - + const testProps = { ...defaultProps, title }; const wrapper = mount( - + ); @@ -194,9 +211,11 @@ describe('Properties', () => { }); test('it renders the lock icon when isDatepickerLocked is true', () => { + const testProps = { ...defaultProps, isDatepickerLocked: true }; + const wrapper = mount( - + ); expect( @@ -223,13 +242,16 @@ describe('Properties', () => { test('it renders a description on the left when the width is at least as wide as the threshold', () => { const description = 'strange'; + const testProps = { ...defaultProps, description }; + + // mockedWidth = showDescriptionThreshold; (useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold }); const wrapper = mount( - + ); @@ -244,6 +266,9 @@ describe('Properties', () => { test('it does NOT render a description on the left when the width is less than the threshold', () => { const description = 'strange'; + const testProps = { ...defaultProps, description }; + + // mockedWidth = showDescriptionThreshold - 1; (useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ @@ -252,7 +277,7 @@ describe('Properties', () => { const wrapper = mount( - + ); @@ -313,10 +338,11 @@ describe('Properties', () => { test('it renders an avatar for the current user viewing the timeline when it has a title', () => { const title = 'port scan'; + const testProps = { ...defaultProps, title }; const wrapper = mount( - + ); @@ -334,9 +360,11 @@ describe('Properties', () => { }); test('insert timeline - new case', async () => { + const testProps = { ...defaultProps, title: 'coolness' }; + const wrapper = mount( - + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); @@ -352,9 +380,11 @@ describe('Properties', () => { }); test('insert timeline - existing case', async () => { + const testProps = { ...defaultProps, title: 'coolness' }; + const wrapper = mount( - + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 8029d166a688a5..40462fa0d09da5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -7,7 +7,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline'; +import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Note } from '../../../../common/lib/note'; import { InputsModelId } from '../../../../common/store/inputs/constants'; @@ -52,7 +52,8 @@ interface Props { isFavorite: boolean; noteIds: string[]; timelineId: string; - status: TimelineStatus; + timelineType: TimelineTypeLiteral; + status: TimelineStatusLiteral; title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; @@ -87,6 +88,7 @@ export const Properties = React.memo( noteIds, status, timelineId, + timelineType, title, toggleLock, updateDescription, @@ -164,10 +166,12 @@ export const Properties = React.memo( isFavorite={isFavorite} noteIds={noteIds} onToggleShowNotes={onToggleShowNotes} + status={status} showDescription={width >= showDescriptionThreshold} showNotes={showNotes} showNotesFromWidth={width >= showNotesThreshold} timelineId={timelineId} + timelineType={timelineType} title={title} toggleLock={onToggleLock} updateDescription={updateDescription} @@ -196,6 +200,7 @@ export const Properties = React.memo( showUsersView={title.length > 0} status={status} timelineId={timelineId} + timelineType={timelineType} title={title} updateDescription={updateDescription} updateNote={updateNote} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx index cd6233334c5de0..b1424848728135 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -11,6 +11,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../../common/mock'; import { createStore, State } from '../../../../common/store'; @@ -26,7 +27,13 @@ jest.mock('../../../../common/lib/kibana', () => { describe('NewTemplateTimeline', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mockClosePopover = jest.fn(); const mockTitle = 'NEW_TIMELINE'; let wrapper: ReactWrapper; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 52766422e49c38..4673ba662b2e98 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -10,9 +10,12 @@ import React from 'react'; import styled from 'styled-components'; import { Description, Name, NotesButton, StarIcon } from './helpers'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; + import { Note } from '../../../../common/lib/note'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; +import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; + import * as i18n from './translations'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; @@ -22,6 +25,7 @@ type UpdateDescription = ({ id, description }: { id: string; description: string interface Props { isFavorite: boolean; timelineId: string; + timelineType: TimelineTypeLiteral; updateIsFavorite: UpdateIsFavorite; showDescription: boolean; description: string; @@ -29,6 +33,7 @@ interface Props { updateTitle: UpdateTitle; updateDescription: UpdateDescription; showNotes: boolean; + status: TimelineStatusLiteral; associateNote: AssociateNote; showNotesFromWidth: boolean; getNotesByIds: (noteIds: string[]) => Note[]; @@ -77,8 +82,10 @@ export const PropertiesLeft = React.memo( showDescription, description, title, + timelineType, updateTitle, updateDescription, + status, showNotes, showNotesFromWidth, associateNote, @@ -120,10 +127,12 @@ export const PropertiesLeft = React.memo( noteIds={noteIds} showNotes={showNotes} size="l" + status={status} text={i18n.NOTES} toggleShowNotes={onToggleShowNotes} toolTip={i18n.NOTES_TOOL_TIP} updateNote={updateNote} + timelineType={timelineType} /> ) : null} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx index ae167515495f7e..a36e841f3f8714 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { PropertiesRight } from './properties_right'; import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { disableTemplate } from '../../../../../common/constants'; jest.mock('../../../../common/lib/kibana', () => { @@ -67,6 +67,7 @@ describe('Properties Right', () => { onOpenTimelineModal: jest.fn(), status: TimelineStatus.active, showTimelineModal: false, + timelineType: TimelineType.default, title: 'title', updateNote: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index e20a3db80d8812..7a9fe85ae402b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -17,7 +17,7 @@ import { import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; import { disableTemplate } from '../../../../../common/constants'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import { useKibana } from '../../../../common/lib/kibana'; @@ -83,9 +83,10 @@ interface PropertiesRightComponentProps { showNotesFromWidth: boolean; showTimelineModal: boolean; showUsersView: boolean; - status: TimelineStatus; + status: TimelineStatusLiteral; timelineId: string; title: string; + timelineType: TimelineTypeLiteral; updateDescription: UpdateDescription; updateNote: UpdateNote; usersViewing: string[]; @@ -111,6 +112,7 @@ const PropertiesRightComponent: React.FC = ({ showTimelineModal, showUsersView, status, + timelineType, timelineId, title, updateDescription, @@ -203,6 +205,8 @@ const PropertiesRightComponent: React.FC = ({ noteIds={noteIds} showNotes={showNotes} size="l" + status={status} + timelineType={timelineType} text={i18n.NOTES} toggleShowNotes={onToggleShowNotes} toolTip={i18n.NOTES_TOOL_TIP} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 2568f41275401d..561f8e513aa090 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -112,7 +112,7 @@ export const NEW_TIMELINE = i18n.translate( export const NEW_TEMPLATE_TIMELINE = i18n.translate( 'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel', { - defaultMessage: 'Create template timeline', + defaultMessage: 'Create new timeline template', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx index 2b67cf75dcff9e..0ff4c0a70fff27 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx @@ -30,11 +30,7 @@ describe('SelectableTimeline', () => { }; }); - const { - SelectableTimeline, - - ORIGINAL_PAGE_SIZE, - } = jest.requireActual('./'); + const { SelectableTimeline, ORIGINAL_PAGE_SIZE } = jest.requireActual('./'); const props = { hideUntitled: false, @@ -94,8 +90,10 @@ describe('SelectableTimeline', () => { sortField: SortFieldTimeline.updated, sortOrder: Direction.desc, }, + status: null, onlyUserFavorite: false, timelineType: TimelineType.default, + templateTimelineType: null, }; beforeAll(() => { mount(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index 56c7c3dcfeb76f..dacaf325130d70 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -33,6 +33,7 @@ import * as i18nTimeline from '../../open_timeline/translations'; import { OpenTimelineResult } from '../../open_timeline/types'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import * as i18n from '../translations'; +import { useTimelineStatus } from '../../open_timeline/use_timeline_status'; const MyEuiFlexItem = styled(EuiFlexItem)` display: inline-block; @@ -118,6 +119,7 @@ const SelectableTimelineComponent: React.FC = ({ const [onlyFavorites, setOnlyFavorites] = useState(false); const [searchRef, setSearchRef] = useState(null); const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline(); + const { timelineStatus, templateTimelineType } = useTimelineStatus({ timelineType }); const onSearchTimeline = useCallback((val) => { setSearchTimelineValue(val); @@ -249,24 +251,31 @@ const SelectableTimelineComponent: React.FC = ({ }, }; - useEffect( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: 1, - pageSize, - }, - search: searchTimelineValue, - sort: { - sortField: SortFieldTimeline.updated, - sortOrder: Direction.desc, - }, - onlyUserFavorite: onlyFavorites, - timelineType, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [onlyFavorites, pageSize, searchTimelineValue, timelineType] - ); + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize, + }, + search: searchTimelineValue, + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, + }, + onlyUserFavorite: onlyFavorites, + status: timelineStatus, + timelineType, + templateTimelineType, + }); + }, [ + fetchAllTimeline, + onlyFavorites, + pageSize, + searchTimelineValue, + timelineType, + timelineStatus, + templateTimelineType, + ]); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 79ec58711e06c4..b58505546c3417 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -24,6 +24,7 @@ import { TimelineComponent, Props as TimelineComponentProps } from './timeline'; import { Sort } from './body/sort'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; +import { TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../../common/lib/kibana'); jest.mock('./properties/properties_right'); @@ -96,6 +97,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg: false, start: startDate, sort, + status: TimelineStatus.active, toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 85e3d5d9478b63..07d4b004d2eda7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -40,6 +40,7 @@ import { IIndexPattern, } from '../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../manage_timeline'; +import { TimelineStatusLiteral } from '../../../../common/types/timeline'; const TimelineContainer = styled.div` height: 100%; @@ -110,6 +111,7 @@ export interface Props { showCallOutUnauthorizedMsg: boolean; start: number; sort: Sort; + status: TimelineStatusLiteral; toggleColumn: (column: ColumnHeaderOptions) => void; usersViewing: string[]; } @@ -141,6 +143,7 @@ export const TimelineComponent: React.FC = ({ show, showCallOutUnauthorizedMsg, start, + status, sort, toggleColumn, usersViewing, @@ -214,6 +217,7 @@ export const TimelineComponent: React.FC = ({ onToggleDataProviderExcluded={onToggleDataProviderExcluded} show={show} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} + status={status} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts index 60d000fe78184a..5cbc922f09c9ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts @@ -13,6 +13,8 @@ export const allTimelinesQuery = gql` $sort: SortTimeline $onlyUserFavorite: Boolean $timelineType: TimelineType + $templateTimelineType: TemplateTimelineType + $status: TimelineStatus ) { getAllTimeline( pageInfo: $pageInfo @@ -20,8 +22,15 @@ export const allTimelinesQuery = gql` sort: $sort onlyUserFavorite: $onlyUserFavorite timelineType: $timelineType + templateTimelineType: $templateTimelineType + status: $status ) { totalCount + defaultTimelineCount + templateTimelineCount + elasticTemplateTimelineCount + customTemplateTimelineCount + favoriteCount timeline { savedObjectId description diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index f025cf15181c3e..17cc0f64de0392 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -22,7 +22,11 @@ import { useApolloClient } from '../../../common/utils/apollo_context'; import { allTimelinesQuery } from './index.gql_query'; import * as i18n from '../../pages/translations'; -import { TimelineTypeLiteralWithNull } from '../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, + TemplateTimelineTypeLiteralWithNull, +} from '../../../../common/types/timeline'; export interface AllTimelinesArgs { fetchAllTimeline: ({ @@ -30,11 +34,17 @@ export interface AllTimelinesArgs { pageInfo, search, sort, + status, timelineType, }: AllTimelinesVariables) => void; timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; + customTemplateTimelineCount: number; + defaultTimelineCount: number; + elasticTemplateTimelineCount: number; + templateTimelineCount: number; + favoriteCount: number; } export interface AllTimelinesVariables { @@ -42,7 +52,9 @@ export interface AllTimelinesVariables { pageInfo: PageInfoTimeline; search: string; sort: SortTimeline; + status: TimelineStatusLiteralWithNull; timelineType: TimelineTypeLiteralWithNull; + templateTimelineType: TemplateTimelineTypeLiteralWithNull; } export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES'; @@ -76,6 +88,7 @@ export const getAllTimeline = memoizeOne( ) : null, savedObjectId: timeline.savedObjectId, + status: timeline.status, title: timeline.title, updated: timeline.updated, updatedBy: timeline.updatedBy, @@ -90,27 +103,39 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount: 0, timelines: [], + customTemplateTimelineCount: 0, + defaultTimelineCount: 0, + elasticTemplateTimelineCount: 0, + templateTimelineCount: 0, + favoriteCount: 0, }); const fetchAllTimeline = useCallback( - ({ onlyUserFavorite, pageInfo, search, sort, timelineType }: AllTimelinesVariables) => { + async ({ + onlyUserFavorite, + pageInfo, + search, + sort, + status, + timelineType, + templateTimelineType, + }: AllTimelinesVariables) => { let didCancel = false; const abortCtrl = new AbortController(); const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines({ - ...allTimelines, - loading: true, - }); + setAllTimelines((prevState) => ({ ...prevState, loading: true })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, pageInfo, search, sort, + status, timelineType, + templateTimelineType, }; const response = await apolloClient.query< GetAllTimeline.Query, @@ -125,8 +150,16 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { }, }, }); - const totalCount = response?.data?.getAllTimeline?.totalCount ?? 0; - const timelines = response?.data?.getAllTimeline?.timeline ?? []; + const getAllTimelineResponse = response?.data?.getAllTimeline; + const totalCount = getAllTimelineResponse?.totalCount ?? 0; + const timelines = getAllTimelineResponse?.timeline ?? []; + const customTemplateTimelineCount = + getAllTimelineResponse?.customTemplateTimelineCount ?? 0; + const defaultTimelineCount = getAllTimelineResponse?.defaultTimelineCount ?? 0; + const elasticTemplateTimelineCount = + getAllTimelineResponse?.elasticTemplateTimelineCount ?? 0; + const templateTimelineCount = getAllTimelineResponse?.templateTimelineCount ?? 0; + const favoriteCount = getAllTimelineResponse?.favoriteCount ?? 0; if (!didCancel) { dispatch( inputsActions.setQuery({ @@ -141,6 +174,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount, timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]), + customTemplateTimelineCount, + defaultTimelineCount, + elasticTemplateTimelineCount, + templateTimelineCount, + favoriteCount, }); } } @@ -155,6 +193,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount: 0, timelines: [], + customTemplateTimelineCount: 0, + defaultTimelineCount: 0, + elasticTemplateTimelineCount: 0, + templateTimelineCount: 0, + favoriteCount: 0, }); } } @@ -165,7 +208,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { abortCtrl.abort(); }; }, - [apolloClient, allTimelines, dispatch, dispatchToaster] + [apolloClient, dispatch, dispatchToaster] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index 26373fa1a825d5..8a2f91d7171f74 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -165,6 +165,7 @@ describe('persistTimeline', () => { }, }, }; + const version = null; const fetchMock = jest.fn(); const postMock = jest.fn(); @@ -180,7 +181,11 @@ describe('persistTimeline', () => { patch: patchMock.mockReturnValue(mockPatchTimelineResponse), }, }); - api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version }); + api.persistTimeline({ + timelineId, + timeline: initialDraftTimeline, + version, + }); }); afterAll(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index a2277897e99bff..fbd89268880dbe 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -12,6 +12,8 @@ import { TimelineResponse, TimelineResponseType, TimelineStatus, + TimelineErrorResponseType, + TimelineErrorResponse, } from '../../../common/types/timeline'; import { TimelineInput, TimelineType } from '../../graphql/types'; import { @@ -48,6 +50,12 @@ const decodeTimelineResponse = (respTimeline?: TimelineResponse) => fold(throwErrors(createToasterPlainError), identity) ); +const decodeTimelineErrorResponse = (respTimeline?: TimelineErrorResponse) => + pipe( + TimelineErrorResponseType.decode(respTimeline), + fold(throwErrors(createToasterPlainError), identity) + ); + const postTimeline = async ({ timeline }: RequestPostTimeline): Promise => { const response = await KibanaServices.get().http.post(TIMELINE_URL, { method: 'POST', @@ -61,12 +69,19 @@ const patchTimeline = async ({ timelineId, timeline, version, -}: RequestPatchTimeline): Promise => { - const response = await KibanaServices.get().http.patch(TIMELINE_URL, { - method: 'PATCH', - body: JSON.stringify({ timeline, timelineId, version }), - }); - +}: RequestPatchTimeline): Promise => { + let response = null; + try { + response = await KibanaServices.get().http.patch(TIMELINE_URL, { + method: 'PATCH', + body: JSON.stringify({ timeline, timelineId, version }), + }); + } catch (err) { + // For Future developer + // We are not rejecting our promise here because we had issue with our RXJS epic + // the issue we were not able to pass the right object to it so we did manage the error in the success + return Promise.resolve(decodeTimelineErrorResponse(err.body)); + } return decodeTimelineResponse(response); }; @@ -74,17 +89,31 @@ export const persistTimeline = async ({ timelineId, timeline, version, -}: RequestPersistTimeline): Promise => { - if (timelineId == null && timeline.status === TimelineStatus.draft) { - const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! }); +}: RequestPersistTimeline): Promise => { + if (timelineId == null && timeline.status === TimelineStatus.draft && timeline) { + const draftTimeline = await cleanDraftTimeline({ + timelineType: timeline.timelineType!, + templateTimelineId: timeline.templateTimelineId ?? undefined, + templateTimelineVersion: timeline.templateTimelineVersion ?? undefined, + }); + + const templateTimelineInfo = + timeline.timelineType! === TimelineType.template + ? { + templateTimelineId: + draftTimeline.data.persistTimeline.timeline.templateTimelineId ?? + timeline.templateTimelineId, + templateTimelineVersion: + draftTimeline.data.persistTimeline.timeline.templateTimelineVersion ?? + timeline.templateTimelineVersion, + } + : {}; return patchTimeline({ timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId, timeline: { ...timeline, - templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId, - templateTimelineVersion: - draftTimeline.data.persistTimeline.timeline.templateTimelineVersion, + ...templateTimelineInfo, }, version: draftTimeline.data.persistTimeline.timeline.version ?? '', }); @@ -147,12 +176,24 @@ export const getDraftTimeline = async ({ export const cleanDraftTimeline = async ({ timelineType, + templateTimelineId, + templateTimelineVersion, }: { timelineType: TimelineType; + templateTimelineId?: string; + templateTimelineVersion?: number; }): Promise => { + const templateTimelineInfo = + timelineType === TimelineType.template + ? { + templateTimelineId, + templateTimelineVersion, + } + : {}; const response = await KibanaServices.get().http.post(TIMELINE_DRAFT_URL, { body: JSON.stringify({ timelineType, + ...templateTimelineInfo, }), }); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index 3ec98d47c67ea6..5a9f80013a3ede 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -30,3 +30,17 @@ export const ERROR_FETCHING_TIMELINES_TITLE = i18n.translate( defaultMessage: 'Failed to query all timelines data', } ); + +export const UPDATE_TIMELINE_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.timelines.updateTimelineErrorTitle', + { + defaultMessage: 'Timeline error', + } +); + +export const UPDATE_TIMELINE_ERROR_TEXT = i18n.translate( + 'xpack.securitySolution.timelines.updateTimelineErrorText', + { + defaultMessage: 'Something went wrong', + } +); 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 55e6849fdb6c4a..8fd75547cc539b 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 @@ -70,6 +70,8 @@ export const createTimeline = actionCreator<{ showCheckboxes?: boolean; showRowRenderers?: boolean; timelineType?: TimelineTypeLiteral; + templateTimelineId?: string; + templateTimelineVersion?: number; }>('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 2155dc804aa7ed..94acb9d92075b7 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -33,7 +33,7 @@ import { Filter, MatchAllFilter, } from '../../../../../../.../../../src/plugins/data/public'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineErrorResponse } from '../../../../common/types/timeline'; import { inputsModel } from '../../../common/store/inputs'; import { TimelineType, @@ -43,6 +43,10 @@ import { } from '../../../graphql/types'; import { addError } from '../../../common/store/app/actions'; +import { persistTimeline } from '../../containers/api'; +import { ALL_TIMELINE_QUERY_ID } from '../../containers/all'; +import * as i18n from '../../pages/translations'; + import { applyKqlFilterQuery, addProvider, @@ -79,8 +83,6 @@ import { isNotNull } from './helpers'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { myEpicTimelineId } from './my_epic_timeline_id'; import { ActionTimeline, TimelineEpicDependencies } from './types'; -import { persistTimeline } from '../../containers/api'; -import { ALL_TIMELINE_QUERY_ID } from '../../containers/all'; const timelineActionsType = [ applyKqlFilterQuery.type, @@ -121,6 +123,7 @@ export const createTimelineEpic = (): Epic< timelineByIdSelector, timelineTimeRangeSelector, apolloClient$, + kibana$, } ) => { const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull)); @@ -146,13 +149,24 @@ export const createTimelineEpic = (): Epic< if (action.type === addError.type) { return true; } - if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) { + if ( + isItAtimelineAction(timelineId) && + timelineObj != null && + timelineObj.status != null && + TimelineStatus.immutable === timelineObj.status + ) { + return false; + } else if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) { myEpicTimelineId.setTimelineVersion(null); myEpicTimelineId.setTimelineId(null); + myEpicTimelineId.setTemplateTimelineId(null); + myEpicTimelineId.setTemplateTimelineVersion(null); } else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) { const addNewTimeline: TimelineModel = get('payload.timeline', action); myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId); myEpicTimelineId.setTimelineVersion(addNewTimeline.version); + myEpicTimelineId.setTemplateTimelineId(addNewTimeline.templateTimelineId); + myEpicTimelineId.setTemplateTimelineVersion(addNewTimeline.templateTimelineVersion); return true; } else if ( timelineActionsType.includes(action.type) && @@ -176,6 +190,8 @@ export const createTimelineEpic = (): Epic< const action: ActionTimeline = get('action', objAction); const timelineId = myEpicTimelineId.getTimelineId(); const version = myEpicTimelineId.getTimelineVersion(); + const templateTimelineId = myEpicTimelineId.getTemplateTimelineId(); + const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion(); if (timelineNoteActionsType.includes(action.type)) { return epicPersistNote( @@ -211,13 +227,37 @@ export const createTimelineEpic = (): Epic< persistTimeline({ timelineId, version, - timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + timeline: { + ...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + templateTimelineId, + templateTimelineVersion, + }, }) ).pipe( - withLatestFrom(timeline$, allTimelineQuery$), - mergeMap(([result, recentTimeline, allTimelineQuery]) => { + withLatestFrom(timeline$, allTimelineQuery$, kibana$), + mergeMap(([result, recentTimeline, allTimelineQuery, kibana]) => { + const error = result as TimelineErrorResponse; + if (error.status_code != null && error.status_code === 405) { + kibana.notifications!.toasts.addDanger({ + title: i18n.UPDATE_TIMELINE_ERROR_TITLE, + text: error.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT, + }); + return [ + endTimelineSaving({ + id: action.payload.id, + }), + ]; + } + const savedTimeline = recentTimeline[action.payload.id]; const response: ResponseTimeline = get('data.persistTimeline', result); + if (response == null) { + return [ + endTimelineSaving({ + id: action.payload.id, + }), + ]; + } const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : []; if (allTimelineQuery.refetch != null) { @@ -264,6 +304,12 @@ export const createTimelineEpic = (): Epic< myEpicTimelineId.setTimelineVersion( updatedTimeline[get('payload.id', checkAction)].version ); + myEpicTimelineId.setTemplateTimelineId( + updatedTimeline[get('payload.id', checkAction)].templateTimelineId + ); + myEpicTimelineId.setTemplateTimelineVersion( + updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion + ); return true; } return false; 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 34778aba7873cc..388869194085c8 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 @@ -15,6 +15,7 @@ import { defaultHeaders, createSecuritySolutionStorageMock, mockIndexPattern, + kibanaObservable, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -38,6 +39,7 @@ import { Direction } from '../../../graphql/types'; import { addTimelineInStorage } from '../../containers/local_storage'; import { isPageTimeline } from './epic_local_storage'; +import { TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../containers/local_storage'); @@ -50,7 +52,13 @@ const addTimelineInStorageMock = addTimelineInStorage as jest.Mock; describe('epicLocalStorage', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); let props = {} as TimelineComponentProps; const sort: Sort = { @@ -63,7 +71,13 @@ describe('epicLocalStorage', () => { const indexPattern = mockIndexPattern; beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); props = { browserFields: mockBrowserFields, columns: defaultHeaders, @@ -89,6 +103,7 @@ describe('epicLocalStorage', () => { show: true, showCallOutUnauthorizedMsg: false, start: startDate, + status: TimelineStatus.active, sort, toggleColumn: jest.fn(), usersViewing: ['elastic'], 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 c0615d36f7a2e0..33770aacde6bba 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 @@ -6,6 +6,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; +import uuid from 'uuid'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { disableTemplate } from '../../../../common/constants'; @@ -19,7 +20,7 @@ import { } from '../../../timelines/components/timeline/data_providers/data_provider'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../graphql/types'; -import { TimelineTypeLiteral } from '../../../../common/types/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../common/types/timeline'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model'; @@ -158,28 +159,38 @@ export const addNewTimeline = ({ showRowRenderers = true, timelineById, timelineType, -}: AddNewTimelineParams): TimelineById => ({ - ...timelineById, - [id]: { - id, - ...timelineDefaults, - columns, - dataProviders, - dateRange, - filters, - itemsPerPage, - kqlQuery, - sort, - show, - savedObjectId: null, - version: null, - isSaving: false, - isLoading: false, - showCheckboxes, - showRowRenderers, - timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, - }, -}); +}: AddNewTimelineParams): TimelineById => { + const templateTimelineInfo = + !disableTemplate && timelineType === TimelineType.template + ? { + templateTimelineId: uuid.v4(), + templateTimelineVersion: 1, + } + : {}; + return { + ...timelineById, + [id]: { + id, + ...timelineDefaults, + columns, + dataProviders, + dateRange, + filters, + itemsPerPage, + kqlQuery, + sort, + show, + savedObjectId: null, + version: null, + isSaving: false, + isLoading: false, + showCheckboxes, + showRowRenderers, + timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, + ...templateTimelineInfo, + }, + }; +}; interface PinTimelineEventParams { id: string; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx index d68c9bd42d974c..6f8666a349d78c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx @@ -7,6 +7,8 @@ export class ManageEpicTimelineId { private timelineId: string | null = null; private version: string | null = null; + private templateTimelineId: string | null = null; + private templateVersion: number | null = null; public getTimelineId(): string | null { return this.timelineId; @@ -16,6 +18,14 @@ export class ManageEpicTimelineId { return this.version; } + public getTemplateTimelineId(): string | null { + return this.templateTimelineId; + } + + public getTemplateTimelineVersion(): number | null { + return this.templateVersion; + } + public setTimelineId(timelineId: string | null) { this.timelineId = timelineId; } @@ -23,4 +33,12 @@ export class ManageEpicTimelineId { public setTimelineVersion(version: string | null) { this.version = version; } + + public setTemplateTimelineId(templateTimelineId: string | null) { + this.templateTimelineId = templateTimelineId; + } + + public setTemplateTimelineVersion(templateVersion: number | null) { + this.templateVersion = templateVersion; + } } 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 e8ea3c8d16e3a8..57895fea8f8ff8 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 @@ -160,7 +160,6 @@ export type SubsetTimelineModel = Readonly< | 'isLoading' | 'savedObjectId' | 'version' - | 'timelineType' | 'status' > >; 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 30b7f73c839d19..4072b4ac2f78b3 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 @@ -137,24 +137,26 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineType = TimelineType.default, filters, } - ) => ({ - ...state, - timelineById: addNewTimeline({ - columns, - dataProviders, - dateRange, - filters, - id, - itemsPerPage, - kqlQuery, - sort, - show, - showCheckboxes, - showRowRenderers, - timelineById: state.timelineById, - timelineType, - }), - }) + ) => { + return { + ...state, + timelineById: addNewTimeline({ + columns, + dataProviders, + dateRange, + filters, + id, + itemsPerPage, + kqlQuery, + sort, + show, + showCheckboxes, + showRowRenderers, + timelineById: state.timelineById, + timelineType, + }), + }; + } ) .case(upsertColumn, (state, { column, id, index }) => ({ ...state, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 65798648f92c63..c64ed608339b67 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -10,6 +10,8 @@ import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { AppApolloClient } from '../../../common/lib/lib'; import { inputsModel } from '../../../common/store/inputs'; import { NotesById } from '../../../common/store/app/model'; +import { StartServices } from '../../../types'; + import { TimelineModel } from './model'; export interface AutoSavedWarningMsg { @@ -53,5 +55,6 @@ export interface TimelineEpicDependencies { selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery; selectNotesByIdSelector: (state: State) => NotesById; apolloClient$: Observable; + kibana$: Observable; storage: Storage; } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 6d59824702cfa9..e212289458ed1b 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -19,6 +19,7 @@ import { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; import { SecurityPluginSetup } from '../../security/public'; +import { AppFrontendLibs } from './common/lib/lib'; export interface SetupPlugins { home: HomePublicPluginSetup; @@ -47,3 +48,7 @@ export type StartServices = CoreStart & export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} + +export interface AppObservableLibs extends AppFrontendLibs { + kibana: CoreStart; +} diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts index a40ef5466c7808..ab729bae6474d2 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts @@ -53,7 +53,9 @@ export const createTimelineResolvers = ( args.pageInfo || null, args.search || null, args.sort || null, - args.timelineType || null + args.status || null, + args.timelineType || null, + args.templateTimelineType || null ); }, }, diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index b9aa8534ab0e9c..a9d07389797dba 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -133,6 +133,12 @@ export const timelineSchema = gql` enum TimelineStatus { active draft + immutable + } + + enum TemplateTimelineType { + elastic + custom } input TimelineInput { @@ -277,6 +283,11 @@ export const timelineSchema = gql` type ResponseTimelines { timeline: [TimelineResult]! totalCount: Float + defaultTimelineCount: Float + templateTimelineCount: Float + elasticTemplateTimelineCount: Float + customTemplateTimelineCount: Float + favoriteCount: Float } ######################### @@ -285,7 +296,7 @@ export const timelineSchema = gql` extend type Query { getOneTimeline(id: ID!): TimelineResult! - getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType): ResponseTimelines! + getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType, templateTimelineType: TemplateTimelineType, status: TimelineStatus): ResponseTimelines! } extend type Mutation { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 40666b61939280..2db3052bae66ff 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -347,6 +347,7 @@ export enum TlsFields { export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export enum TimelineType { @@ -361,6 +362,11 @@ export enum SortFieldTimeline { created = 'created', } +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + export enum NetworkDirectionEcs { inbound = 'inbound', outbound = 'outbound', @@ -2119,6 +2125,16 @@ export interface ResponseTimelines { timeline: (Maybe)[]; totalCount?: Maybe; + + defaultTimelineCount?: Maybe; + + templateTimelineCount?: Maybe; + + elasticTemplateTimelineCount?: Maybe; + + customTemplateTimelineCount?: Maybe; + + favoriteCount?: Maybe; } export interface Mutation { @@ -2256,6 +2272,10 @@ export interface GetAllTimelineQueryArgs { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -2714,6 +2734,10 @@ export namespace QueryResolvers { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } } @@ -8670,6 +8694,24 @@ export namespace ResponseTimelinesResolvers { timeline?: TimelineResolver<(Maybe)[], TypeParent, TContext>; totalCount?: TotalCountResolver, TypeParent, TContext>; + + defaultTimelineCount?: DefaultTimelineCountResolver, TypeParent, TContext>; + + templateTimelineCount?: TemplateTimelineCountResolver, TypeParent, TContext>; + + elasticTemplateTimelineCount?: ElasticTemplateTimelineCountResolver< + Maybe, + TypeParent, + TContext + >; + + customTemplateTimelineCount?: CustomTemplateTimelineCountResolver< + Maybe, + TypeParent, + TContext + >; + + favoriteCount?: FavoriteCountResolver, TypeParent, TContext>; } export type TimelineResolver< @@ -8682,6 +8724,31 @@ export namespace ResponseTimelinesResolvers { Parent = ResponseTimelines, TContext = SiemContext > = Resolver; + export type DefaultTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type TemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type ElasticTemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type CustomTemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type FavoriteCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; } export namespace MutationResolvers { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/README.md index 7a48df72d6bdec..fa0716ec082858 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/README.md @@ -59,7 +59,7 @@ which will: - Delete any existing alerts you have - Delete any existing alert tasks you have - Delete any existing signal mapping, policies, and template, you might have previously had. -- Add the latest signal index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.security_solution.signalsIndex`. +- Add the latest signal index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.securitySolution.signalsIndex`. - Posts the sample rule from `./rules/queries/query_with_rule_id.json` - The sample rule checks for root or admin every 5 minutes and reports that as a signal if it is a positive hit @@ -171,6 +171,6 @@ go about doing so. To test out the functionality of large lists with rules, the user will need to import a list and post a rule with a reference to that exception list. The following outlines an example using the sample json rule provided in the repo. * First, set the appropriate env var in order to enable exceptions features`export ELASTIC_XPACK_SECURITY_SOLUTION_LISTS_FEATURE=true` and `export ELASTIC_XPACK_SECURITY_SOLUTION_EXCEPTIONS_LISTS=true` and start kibana -* Second, import a list of ips from a file called `ci-badguys.txt`. The command should look like this: +* Second, import a list of ips from a file called `ci-badguys.txt`. The command should look like this: `cd $HOME/kibana/x-pack/plugins/lists/server/scripts && ./import_list_items_by_filename.sh ip ~/ci-badguys.txt` * Then, from the detection engine scripts folder (`cd kibana/x-pack/plugins/security_solution/server/lib/detection_engine/scripts`) run `./post_rule.sh rules/queries/lists/query_with_list_plugin.json` diff --git a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts index 281726d488abe8..68e7f8d5e6fe19 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { isEmpty } from 'lodash/fp'; import { AuthenticatedUser } from '../../../../security/common/model'; import { UNAUTHENTICATED_USER } from '../../../common/constants'; @@ -28,18 +27,13 @@ export const pickSavedTimeline = ( savedTimeline.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; } - if (savedTimeline.timelineType === TimelineType.template) { - if (savedTimeline.templateTimelineId == null) { - // create template timeline - savedTimeline.templateTimelineId = uuid.v4(); - savedTimeline.templateTimelineVersion = 1; - } else { - // update template timeline - if (savedTimeline.templateTimelineVersion != null) { - savedTimeline.templateTimelineVersion = savedTimeline.templateTimelineVersion + 1; - } - } - } else { + if (savedTimeline.status === TimelineStatus.draft) { + savedTimeline.status = !isEmpty(savedTimeline.title) + ? TimelineStatus.active + : TimelineStatus.draft; + } + + if (savedTimeline.timelineType === TimelineType.default) { savedTimeline.timelineType = savedTimeline.timelineType ?? TimelineType.default; savedTimeline.templateTimelineId = null; savedTimeline.templateTimelineVersion = null; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts index 7180f06d853bef..adfdf831f22cf4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -138,6 +138,7 @@ export const mockGetTimelineValue = { kqlMode: 'filter', kqlQuery: { filterQuery: [] }, title: 'My duplicate timeline', + timelineType: TimelineType.default, dateRange: { start: 1584523907294, end: 1584610307294 }, savedQueryId: null, sort: { columnId: '@timestamp', sortDirection: 'desc' }, @@ -145,17 +146,25 @@ export const mockGetTimelineValue = { createdBy: 'angela', updated: 1584868346013, updatedBy: 'angela', - noteIds: [], + noteIds: ['d2649d40-6bc5-xxxx-0000-5db0048c6086'], pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], }; export const mockGetTemplateTimelineValue = { ...mockGetTimelineValue, timelineType: TimelineType.template, - templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + templateTimelineId: '79deb4c0-6bc1-0000-0000-f5341fb7a189', templateTimelineVersion: 1, }; +export const mockUniqueParsedTemplateTimelineObjects = [ + { ...mockUniqueParsedObjects[0], ...mockGetTemplateTimelineValue, templateTimelineVersion: 2 }, +]; + +export const mockParsedTemplateTimelineObjects = [ + { ...mockParsedObjects[0], ...mockGetTemplateTimelineValue }, +]; + export const mockGetDraftTimelineValue = { savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', version: 'WzEyMjUsMV0=', @@ -195,8 +204,51 @@ export const mockParsedTimelineObject = omit( mockUniqueParsedObjects[0] ); +export const mockParsedTemplateTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + mockUniqueParsedTemplateTimelineObjects[0] +); + export const mockGetCurrentUser = { user: { username: 'mockUser', }, }; + +export const mockCreatedTimeline = { + savedObjectId: '79deb4c0-1111-1111-1111-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], +}; + +export const mockCreatedTemplateTimeline = { + ...mockCreatedTimeline, + ...mockGetTemplateTimelineValue, +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts index 0b320459c76a8c..9afe5ad533324f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import * as rt from 'io-ts'; +import stream from 'stream'; + import { TIMELINE_DRAFT_URL, TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL, TIMELINE_URL, } from '../../../../../common/constants'; -import stream from 'stream'; -import { requestMock } from '../../../detection_engine/routes/__mocks__'; import { SavedTimeline, TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; + +import { requestMock } from '../../../detection_engine/routes/__mocks__'; + import { updateTimelineSchema } from '../schemas/update_timelines_schema'; import { createTimelineSchema } from '../schemas/create_timelines_schema'; @@ -59,7 +62,7 @@ export const inputTimeline: SavedTimeline = { title: 't', timelineType: TimelineType.default, templateTimelineId: null, - templateTimelineVersion: null, + templateTimelineVersion: 1, dateRange: { start: 1585227005527, end: 1585313405527 }, savedQueryId: null, sort: { columnId: '@timestamp', sortDirection: 'desc' }, @@ -68,7 +71,7 @@ export const inputTimeline: SavedTimeline = { export const inputTemplateTimeline = { ...inputTimeline, timelineType: TimelineType.template, - templateTimelineId: null, + templateTimelineId: '79deb4c0-6bc1-11ea-inpt-templatea189', templateTimelineVersion: null, }; @@ -90,11 +93,11 @@ export const createDraftTimelineWithoutTimelineId = { }; export const createTemplateTimelineWithoutTimelineId = { - templateTimelineId: null, timeline: inputTemplateTimeline, timelineId: null, version: null, timelineType: TimelineType.template, + status: TimelineStatus.active, }; export const createTimelineWithTimelineId = { @@ -110,7 +113,6 @@ export const createDraftTimelineWithTimelineId = { export const createTemplateTimelineWithTimelineId = { ...createTemplateTimelineWithoutTimelineId, timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', - templateTimelineId: 'existing template timeline id', }; export const updateTimelineWithTimelineId = { @@ -122,7 +124,7 @@ export const updateTimelineWithTimelineId = { export const updateTemplateTimelineWithTimelineId = { timeline: { ...inputTemplateTimeline, - templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + templateTimelineId: '79deb4c0-6bc1-0000-0000-f5341fb7a189', templateTimelineVersion: 1, }, timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts index 9ad50b8f2266cd..8cabd84a965b75 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { IRouter } from '../../../../../../../src/core/server'; import { ConfigType } from '../../..'; import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; @@ -14,6 +15,7 @@ import { buildRouteValidation } from '../../../utils/build_validation/route_vali import { getDraftTimeline, resetTimeline, getTimeline, persistTimeline } from '../saved_object'; import { draftTimelineDefaults } from '../default_timeline'; import { cleanDraftTimelineSchema } from './schemas/clean_draft_timelines_schema'; +import { TimelineType } from '../../../../common/types/timeline'; export const cleanDraftTimelinesRoute = ( router: IRouter, @@ -60,10 +62,18 @@ export const cleanDraftTimelinesRoute = ( }, }); } + const templateTimelineData = + request.body.timelineType === TimelineType.template + ? { + timelineType: request.body.timelineType, + templateTimelineId: uuid.v4(), + templateTimelineVersion: 1, + } + : {}; const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { ...draftTimelineDefaults, - timelineType: request.body.timelineType, + ...templateTimelineData, }); if (newTimelineResponse.code === 200) { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts index 70ee1532395a5d..f5345c3dce2223 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts @@ -23,6 +23,7 @@ import { createTimelineWithTimelineId, createTemplateTimelineWithoutTimelineId, createTemplateTimelineWithTimelineId, + updateTemplateTimelineWithTimelineId, } from './__mocks__/request_responses'; import { CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, @@ -34,6 +35,7 @@ describe('create timelines', () => { let securitySetup: SecurityPluginSetup; let { context } = requestContextMock.createTools(); let mockGetTimeline: jest.Mock; + let mockGetTemplateTimeline: jest.Mock; let mockPersistTimeline: jest.Mock; let mockPersistPinnedEventOnTimeline: jest.Mock; let mockPersistNote: jest.Mock; @@ -55,6 +57,7 @@ describe('create timelines', () => { } as unknown) as SecurityPluginSetup; mockGetTimeline = jest.fn(); + mockGetTemplateTimeline = jest.fn(); mockPersistTimeline = jest.fn(); mockPersistPinnedEventOnTimeline = jest.fn(); mockPersistNote = jest.fn(); @@ -231,11 +234,14 @@ describe('create timelines', () => { }); }); - describe('Import a template timeline already exist', () => { + describe('Create a template timeline already exist', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), persistTimeline: mockPersistTimeline, }; }); @@ -259,7 +265,7 @@ describe('create timelines', () => { test('returns error message', async () => { const response = await server.inject( - getCreateTimelinesRequest(createTemplateTimelineWithTimelineId), + getCreateTimelinesRequest(updateTemplateTimelineWithTimelineId), context ); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts index d92f2ce0764c5c..60ddaea367aedd 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts @@ -6,7 +6,6 @@ import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_URL } from '../../../../common/constants'; -import { TimelineType } from '../../../../common/types/timeline'; import { ConfigType } from '../../..'; import { SetupPlugins } from '../../../plugin'; @@ -15,14 +14,12 @@ import { buildRouteValidation } from '../../../utils/build_validation/route_vali import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; import { createTimelineSchema } from './schemas/create_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; import { - createTimelines, - getTimeline, - getTemplateTimeline, - CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, - CREATE_TIMELINE_ERROR_MESSAGE, -} from './utils/create_timelines'; + buildFrameworkRequest, + CompareTimelinesStatus, + TimelineStatusActions, +} from './utils/common'; +import { createTimelines } from './utils/create_timelines'; export const createTimelinesRoute = ( router: IRouter, @@ -36,7 +33,7 @@ export const createTimelinesRoute = ( body: buildRouteValidation(createTimelineSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:siem'], }, }, async (context, request, response) => { @@ -46,40 +43,54 @@ export const createTimelinesRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const { timelineId, timeline, version } = request.body; - const { templateTimelineId, timelineType } = timeline; - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - - const existTimeline = - timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null; - const existTemplateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; + const { + templateTimelineId, + templateTimelineVersion, + timelineType, + title, + status, + } = timeline; + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + title, + timelineType, + timelineInput: { + id: timelineId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, + }); + await compareTimelinesStatus.init(); - if ( - (!isHandlingTemplateTimeline && existTimeline != null) || - (isHandlingTemplateTimeline && (existTemplateTimeline != null || existTimeline != null)) - ) { - return siemResponse.error({ - body: isHandlingTemplateTimeline - ? CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE - : CREATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, + // Create timeline + if (compareTimelinesStatus.isCreatable) { + const newTimeline = await createTimelines({ + frameworkRequest, + timeline, + timelineVersion: version, }); - } - // Create timeline - const newTimeline = await createTimelines(frameworkRequest, timeline, null, version); - return response.ok({ - body: { - data: { - persistTimeline: newTimeline, + return response.ok({ + body: { + data: { + persistTimeline: newTimeline, + }, }, - }, - }); + }); + } else { + return siemResponse.error( + compareTimelinesStatus.checkIsFailureCases(TimelineStatusActions.create) || { + statusCode: 405, + body: 'update timeline error', + } + ); + } } catch (err) { const error = transformError(err); - return siemResponse.error({ body: error.message, statusCode: error.statusCode, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 48e22f6af2a7b8..15fb8f3411cfab 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -12,7 +12,7 @@ import { createMockConfig, } from '../../detection_engine/routes/__mocks__'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; import { SecurityPluginSetup } from '../../../../../../plugins/security/server'; import { @@ -22,7 +22,19 @@ import { mockGetCurrentUser, mockGetTimelineValue, mockParsedTimelineObject, + mockParsedTemplateTimelineObjects, + mockUniqueParsedTemplateTimelineObjects, + mockParsedTemplateTimelineObject, + mockCreatedTemplateTimeline, + mockGetTemplateTimelineValue, + mockCreatedTimeline, } from './__mocks__/import_timelines'; +import { + TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + EMPTY_TITLE_ERROR_MESSAGE, + NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, + NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, +} from './utils/failure_cases'; describe('import timelines', () => { let server: ReturnType; @@ -35,8 +47,7 @@ describe('import timelines', () => { let mockPersistPinnedEventOnTimeline: jest.Mock; let mockPersistNote: jest.Mock; let mockGetTupleDuplicateErrorsAndUniqueTimeline: jest.Mock; - const newTimelineSavedObjectId = '79deb4c0-6bc1-11ea-9999-f5341fb7a189'; - const newTimelineVersion = '9999'; + beforeEach(() => { jest.resetModules(); jest.resetAllMocks(); @@ -90,7 +101,7 @@ describe('import timelines', () => { getTimeline: mockGetTimeline.mockReturnValue(null), getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), persistTimeline: mockPersistTimeline.mockReturnValue({ - timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, + timeline: mockCreatedTimeline, }), }; }); @@ -139,19 +150,38 @@ describe('import timelines', () => { test('should Create a new timeline savedObject with given timeline', async () => { const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTimelineObject); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...mockParsedTimelineObject, + status: TimelineStatus.active, + templateTimelineId: null, + templateTimelineVersion: null, + }); }); - test('should Create a new timeline savedObject with given draft timeline', async () => { + test('should throw error if given an untitle timeline', async () => { mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ mockDuplicateIdErrors, - [{ ...mockUniqueParsedObjects[0], status: TimelineStatus.draft }], + [ + { + ...mockUniqueParsedObjects[0], + title: '', + }, + ], ]); const mockRequest = getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ - ...mockParsedTimelineObject, - status: TimelineStatus.active, + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: EMPTY_TITLE_ERROR_MESSAGE, + }, + }, + ], }); }); @@ -178,7 +208,9 @@ describe('import timelines', () => { test('should Create a new pinned event with new timelineSavedObjectId', async () => { const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual(newTimelineSavedObjectId); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual( + mockCreatedTimeline.savedObjectId + ); }); test('should Create notes', async () => { @@ -202,7 +234,7 @@ describe('import timelines', () => { test('should provide note content when Creating notes for a timeline', async () => { const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][2]).toEqual(newTimelineVersion); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTimeline.version); }); test('should provide new notes when Creating notes for a timeline', async () => { @@ -211,17 +243,17 @@ describe('import timelines', () => { expect(mockPersistNote.mock.calls[0][3]).toEqual({ eventId: undefined, note: mockUniqueParsedObjects[0].globalNotes[0].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); expect(mockPersistNote.mock.calls[1][3]).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, note: mockUniqueParsedObjects[0].eventNotes[0].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); expect(mockPersistNote.mock.calls[2][3]).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, note: mockUniqueParsedObjects[0].eventNotes[1].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); }); @@ -268,7 +300,458 @@ describe('import timelines', () => { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', error: { status_code: 409, - message: `timeline_id: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + message: `savedObjectId: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + }, + }, + ], + }); + }); + + test('should throw error if given an untitle timeline', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedObjects[0], + title: '', + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: EMPTY_TITLE_ERROR_MESSAGE, + }, + }, + ], + }); + }); + + test('should throw error if timelineType updated', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockGetTimelineValue, + timelineType: TimelineType.template, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + }, + }, + ], + }); + }); + }); + + describe('request validation', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' }, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue( + new Error('Test error') + ), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + }); + test('disallows invalid query', async () => { + request = requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + [ + 'Invalid value "undefined" supplied to "file"', + 'Invalid value "undefined" supplied to "file"', + ].join(',') + ); + }); + }); +}); + +describe('import template timelines', () => { + let server: ReturnType; + let request: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetTimeline: jest.Mock; + let mockGetTemplateTimeline: jest.Mock; + let mockPersistTimeline: jest.Mock; + let mockPersistPinnedEventOnTimeline: jest.Mock; + let mockPersistNote: jest.Mock; + let mockGetTupleDuplicateErrorsAndUniqueTimeline: jest.Mock; + const mockNewTemplateTimelineId = 'new templateTimelineId'; + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + mockGetTimeline = jest.fn(); + mockGetTemplateTimeline = jest.fn(); + mockPersistTimeline = jest.fn(); + mockPersistPinnedEventOnTimeline = jest.fn(); + mockPersistNote = jest.fn(); + mockGetTupleDuplicateErrorsAndUniqueTimeline = jest.fn(); + + jest.doMock('../create_timelines_stream_from_ndjson', () => { + return { + createTimelinesStreamFromNdJson: jest + .fn() + .mockReturnValue(mockParsedTemplateTimelineObjects), + }; + }); + + jest.doMock('../../../../../../../src/legacy/utils', () => { + return { + createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedTemplateTimelineObjects), + }; + }); + + jest.doMock('./utils/import_timelines', () => { + const originalModule = jest.requireActual('./utils/import_timelines'); + return { + ...originalModule, + getTupleDuplicateErrorsAndUniqueTimeline: mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue( + [mockDuplicateIdErrors, mockUniqueParsedTemplateTimelineObjects] + ), + }; + }); + + jest.doMock('uuid', () => ({ + v4: jest.fn().mockReturnValue(mockNewTemplateTimelineId), + })); + }); + + describe('Import a new template timeline', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: mockCreatedTemplateTimeline, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should use given templateTimelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId + ); + }); + + test('should Create a new timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should Create a new timeline savedObject without timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); + }); + + test('should Create a new timeline savedObject witn given timeline and skip the omitted fields', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...mockParsedTemplateTimelineObject, + status: TimelineStatus.active, + }); + }); + + test('should NOT Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).not.toHaveBeenCalled(); + }); + + test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + }); + + test('should exclude event notes when creating notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, + timelineId: mockCreatedTemplateTimeline.savedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('should assign a templateTimeline Id automatically if not given one', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + templateTimelineId: null, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3].templateTimelineId).toEqual( + mockNewTemplateTimelineId + ); + }); + }); + + describe('Import a template timeline already exist', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: mockCreatedTemplateTimeline, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should use given templateTimelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId + ); + }); + + test('should UPDATE timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should UPDATE timeline savedObject with timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should UPDATE timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].version + ); + }); + + test('should UPDATE a new timeline savedObject witn given timeline and skip the omitted fields', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTemplateTimelineObject); + }); + + test('should NOT Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).not.toHaveBeenCalled(); + }); + + test('should provide noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + }); + + test('should exclude event notes when creating notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, + timelineId: mockCreatedTemplateTimeline.savedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('should throw error if with given template timeline version conflict', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + templateTimelineVersion: 1, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + }, + }, + ], + }); + }); + + test('should throw error if status updated', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + status: TimelineStatus.immutable, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts index 5080142f22b155..fb4991d7d1e7d1 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts @@ -7,17 +7,17 @@ import { extname } from 'path'; import { chunk, omit } from 'lodash/fp'; -import { validate } from '../../../../common/validate'; -import { importRulesSchema } from '../../../../common/detection_engine/schemas/response/import_rules_schema'; +import uuid from 'uuid'; import { createPromiseFromStreams } from '../../../../../../../src/legacy/utils'; import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; +import { validate } from '../../../../common/validate'; import { SetupPlugins } from '../../../plugin'; import { ConfigType } from '../../../config'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; - +import { importRulesSchema } from '../../../../common/detection_engine/schemas/response/import_rules_schema'; import { buildSiemResponse, createBulkErrorObject, @@ -28,7 +28,11 @@ import { import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; +import { + buildFrameworkRequest, + CompareTimelinesStatus, + TimelineStatusActions, +} from './utils/common'; import { getTupleDuplicateErrorsAndUniqueTimeline, isBulkError, @@ -38,11 +42,11 @@ import { PromiseFromStreams, timelineSavedObjectOmittedFields, } from './utils/import_timelines'; -import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines'; -import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; -import { checkIsFailureCases } from './utils/update_timelines'; +import { createTimelines } from './utils/create_timelines'; +import { TimelineStatus } from '../../../../common/types/timeline'; const CHUNK_PARSED_OBJECT_SIZE = 10; +const DEFAULT_IMPORT_ERROR = `Something went wrong, there's something we didn't handle properly, please help us improve by providing the file you try to import on https://discuss.elastic.co/c/security/siem`; export const importTimelinesRoute = ( router: IRouter, @@ -118,100 +122,112 @@ export const importTimelinesRoute = ( return null; } + const { - savedObjectId = null, + savedObjectId, pinnedEventIds, globalNotes, eventNotes, + status, templateTimelineId, templateTimelineVersion, + title, timelineType, - version = null, + version, } = parsedTimeline; const parsedTimelineObject = omit( timelineSavedObjectOmittedFields, parsedTimeline ); - let newTimeline = null; try { - const templateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; - - const timeline = - savedObjectId != null && - (await getTimeline(frameworkRequest, savedObjectId)); - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - - if ( - (timeline == null && !isHandlingTemplateTimeline) || - (timeline == null && templateTimeline == null && isHandlingTemplateTimeline) - ) { + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + timelineType, + title, + timelineInput: { + id: savedObjectId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, + }); + await compareTimelinesStatus.init(); + const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; + if (compareTimelinesStatus.isCreatableViaImport) { // create timeline / template timeline - newTimeline = await createTimelines( + newTimeline = await createTimelines({ frameworkRequest, - { + timeline: { ...parsedTimelineObject, status: - parsedTimelineObject.status === TimelineStatus.draft + status === TimelineStatus.draft ? TimelineStatus.active - : parsedTimelineObject.status, + : status ?? TimelineStatus.active, + templateTimelineVersion: isTemplateTimeline + ? templateTimelineVersion + : null, + templateTimelineId: isTemplateTimeline + ? templateTimelineId ?? uuid.v4() + : null, }, - null, // timelineSavedObjectId - null, // timelineVersion - pinnedEventIds, - isHandlingTemplateTimeline - ? globalNotes - : [...globalNotes, ...eventNotes], - [] // existing note ids - ); + pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, + notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], + }); resolve({ timeline_id: newTimeline.timeline.savedObjectId, status_code: 200, }); - } else if ( - timeline && - timeline != null && - templateTimeline != null && - isHandlingTemplateTimeline - ) { - // update template timeline - const errorObj = checkIsFailureCases( - isHandlingTemplateTimeline, - version, - templateTimelineVersion ?? null, - timeline, - templateTimeline - ); - if (errorObj != null) { - return siemResponse.error(errorObj); - } + } - newTimeline = await createTimelines( - frameworkRequest, - { ...parsedTimelineObject, templateTimelineId, templateTimelineVersion }, - timeline.savedObjectId, // timelineSavedObjectId - timeline.version, // timelineVersion - pinnedEventIds, - globalNotes, - [] // existing note ids + if (!compareTimelinesStatus.isHandlingTemplateTimeline) { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.createViaImport ); + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; - resolve({ - timeline_id: newTimeline.timeline.savedObjectId, - status_code: 200, - }); - } else { resolve( createBulkErrorObject({ id: savedObjectId ?? 'unknown', statusCode: 409, - message: `timeline_id: "${savedObjectId}" already exists`, + message, }) ); + } else { + if (compareTimelinesStatus.isUpdatableViaImport) { + // update template timeline + newTimeline = await createTimelines({ + frameworkRequest, + timeline: parsedTimelineObject, + timelineSavedObjectId: compareTimelinesStatus.timelineId, + timelineVersion: compareTimelinesStatus.timelineVersion, + notes: globalNotes, + existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, + }); + + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + }); + } else { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.updateViaImport + ); + + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; + + resolve( + createBulkErrorObject({ + id: savedObjectId ?? 'unknown', + statusCode: 409, + message, + }) + ); + } } } catch (err) { resolve( @@ -236,9 +252,9 @@ export const importTimelinesRoute = ( ]; } - const errorsResp = importTimelineResponse.filter((resp) => - isBulkError(resp) - ) as BulkError[]; + const errorsResp = importTimelineResponse.filter((resp) => { + return isBulkError(resp); + }) as BulkError[]; const successes = importTimelineResponse.filter((resp) => { if (isImportRegular(resp)) { return resp.status_code === 200; @@ -261,7 +277,6 @@ export const importTimelinesRoute = ( } catch (err) { const error = transformError(err); const siemResponse = buildSiemResponse(response); - return siemResponse.error({ body: error.message, statusCode: error.statusCode, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts index 2a3feb7afd59c2..3cedb925649a27 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts @@ -26,7 +26,7 @@ import { import { UPDATE_TIMELINE_ERROR_MESSAGE, UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, -} from './utils/update_timelines'; +} from './utils/failure_cases'; describe('update timelines', () => { let server: ReturnType; @@ -93,7 +93,7 @@ describe('update timelines', () => { await server.inject(mockRequest, context); }); - test('should Check a if given timeline id exist', async () => { + test('should Check if given timeline id exist', async () => { expect(mockGetTimeline.mock.calls[0][1]).toEqual(updateTimelineWithTimelineId.timelineId); }); @@ -178,7 +178,7 @@ describe('update timelines', () => { timeline: [mockGetTemplateTimelineValue], }), persistTimeline: mockPersistTimeline.mockReturnValue({ - timeline: updateTimelineWithTimelineId.timeline, + timeline: updateTemplateTimelineWithTimelineId.timeline, }), }; }); @@ -211,7 +211,7 @@ describe('update timelines', () => { test('should Update existing template timeline with template timelineId', async () => { expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( - updateTemplateTimelineWithTimelineId.timelineId + updateTemplateTimelineWithTimelineId.timeline.templateTimelineId ); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts index d5ecd408a6ef45..f59df151b69550 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts @@ -7,19 +7,17 @@ import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_URL } from '../../../../common/constants'; -import { TimelineType } from '../../../../common/types/timeline'; import { SetupPlugins } from '../../../plugin'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; import { ConfigType } from '../../..'; import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; -import { FrameworkRequest } from '../../framework'; import { updateTimelineSchema } from './schemas/update_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; -import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines'; -import { checkIsFailureCases } from './utils/update_timelines'; +import { buildFrameworkRequest, TimelineStatusActions } from './utils/common'; +import { createTimelines } from './utils/create_timelines'; +import { CompareTimelinesStatus } from './utils/compare_timelines_status'; export const updateTimelinesRoute = ( router: IRouter, @@ -33,7 +31,7 @@ export const updateTimelinesRoute = ( body: buildRouteValidation(updateTimelineSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:siem'], }, }, // eslint-disable-next-line complexity @@ -43,39 +41,54 @@ export const updateTimelinesRoute = ( try { const frameworkRequest = await buildFrameworkRequest(context, security, request); const { timelineId, timeline, version } = request.body; - const { templateTimelineId, templateTimelineVersion, timelineType } = timeline; - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - const existTimeline = - timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null; + const { + templateTimelineId, + templateTimelineVersion, + timelineType, + title, + status, + } = timeline; - const existTemplateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; - - const errorObj = checkIsFailureCases( - isHandlingTemplateTimeline, - version, - templateTimelineVersion ?? null, - existTimeline, - existTemplateTimeline - ); - if (errorObj != null) { - return siemResponse.error(errorObj); - } - const updatedTimeline = await createTimelines( - (frameworkRequest as unknown) as FrameworkRequest, - timeline, - timelineId, - version - ); - return response.ok({ - body: { - data: { - persistTimeline: updatedTimeline, - }, + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + title, + timelineType, + timelineInput: { + id: timelineId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, }, + frameworkRequest, }); + + await compareTimelinesStatus.init(); + if (compareTimelinesStatus.isUpdatable) { + const updatedTimeline = await createTimelines({ + frameworkRequest, + timeline, + timelineSavedObjectId: timelineId, + timelineVersion: version, + }); + + return response.ok({ + body: { + data: { + persistTimeline: updatedTimeline, + }, + }, + }); + } else { + const error = compareTimelinesStatus.checkIsFailureCases(TimelineStatusActions.update); + return siemResponse.error( + error || { + statusCode: 405, + body: 'update timeline error', + } + ); + } } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts index adbfdbf6d6051c..2c2d651fd483be 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts @@ -5,9 +5,10 @@ */ import { set } from 'lodash/fp'; -import { RequestHandlerContext } from 'src/core/server'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; + import { SetupPlugins } from '../../../../plugin'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; + import { FrameworkRequest } from '../../../framework'; export const buildFrameworkRequest = async ( @@ -28,3 +29,19 @@ export const buildFrameworkRequest = async ( ) ); }; + +export enum TimelineStatusActions { + create = 'create', + createViaImport = 'createViaImport', + update = 'update', + updateViaImport = 'updateViaImport', +} + +export type TimelineStatusAction = + | TimelineStatusActions.create + | TimelineStatusActions.createViaImport + | TimelineStatusActions.update + | TimelineStatusActions.updateViaImport; + +export * from './compare_timelines_status'; +export * from './timeline_object'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts new file mode 100644 index 00000000000000..a6d379e534bc28 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts @@ -0,0 +1,810 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; +import { FrameworkRequest } from '../../../framework'; + +import { + mockUniqueParsedObjects, + mockUniqueParsedTemplateTimelineObjects, + mockGetTemplateTimelineValue, + mockGetTimelineValue, +} from '../__mocks__/import_timelines'; + +import { CompareTimelinesStatus as TimelinesStatusType } from './compare_timelines_status'; +import { + EMPTY_TITLE_ERROR_MESSAGE, + UPDATE_STATUS_ERROR_MESSAGE, + UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + getImportExistingTimelineError, +} from './failure_cases'; +import { TimelineStatusActions } from './common'; + +describe('CompareTimelinesStatus', () => { + describe('timeline', () => { + describe('given timeline exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should not creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test('should not CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should indicate we are handling a timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(false); + }); + }); + + describe('given timeline does NOT exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test('should be CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should indicate we are handling a timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(false); + }); + }); + }); + + describe('template timeline', () => { + describe('given template timeline exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should not creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test('should not CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test('should be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(true); + }); + + test('should indicate we are handling a template timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); + }); + }); + + describe('given template timeline does NOT exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline, + getTimelineByTemplateTimelineId: mockGetTemplateTimeline, + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test('should throw no error on creatable', () => { + expect(timelineObj.checkIsFailureCases(TimelineStatusActions.create)).toBeNull(); + }); + + test('should be CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test('should throw no error on CreatableViaImport', () => { + expect(timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport)).toBeNull(); + }); + + test('should not be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test('should throw error when updat', () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should throw error when UpdatableViaImport', () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should indicate we are handling a template timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); + }); + }); + }); + + describe(`Throw error if given title does NOT exists`, () => { + describe('timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: null, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be creatable`, () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error on create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be creatable via import`, () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when create via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + }); + + describe('template timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: null, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be creatable`, () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error on create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be creatable via import`, () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when create via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test(`throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + }); + }); + + describe(`Throw error if timeline status is updated`, () => { + describe('immutable timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue({ + ...mockGetTimelineValue, + status: TimelineStatus.immutable, + }), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: 'mock title', + status: TimelineStatus.immutable, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be updatable if existing status is immutable`, () => { + expect(timelineObj.isUpdatable).toBe(false); + }); + + test(`should throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_STATUS_ERROR_MESSAGE); + }); + + test(`should not be updatable via import if existing status is immutable`, () => { + expect(timelineObj.isUpdatableViaImport).toBe(false); + }); + + test(`should throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual( + getImportExistingTimelineError(mockUniqueParsedObjects[0].savedObjectId) + ); + }); + }); + + describe('immutable template timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue({ + ...mockGetTemplateTimelineValue, + status: TimelineStatus.immutable, + }), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [{ ...mockGetTemplateTimelineValue, status: TimelineStatus.immutable }], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + title: 'mock title', + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be able to update`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`should not throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_STATUS_ERROR_MESSAGE); + }); + + test(`should not be able to update via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(true); + }); + + test(`should not throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toBeUndefined(); + }); + }); + }); + + describe('If create template timeline without template timeline id', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline, + getTimelineByTemplateTimelineId: mockGetTemplateTimeline, + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: null, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should not be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test(`throw no error when create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toBeUndefined(); + }); + + test('should be Creatable via import', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test(`throw no error when CreatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toBeUndefined(); + }); + }); + + describe('Throw error if template timeline version is conflict when update via import', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockGetTemplateTimelineValue.templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should not be creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error when create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should not be Creatable via import', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when CreatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual( + getImportExistingTimelineError(mockUniqueParsedObjects[0].savedObjectId) + ); + }); + + test('should be updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test(`throw no error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error).toBeNull(); + }); + + test('should not be updatable via import', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test(`throw error when UpdatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts new file mode 100644 index 00000000000000..d61d217a4cf492 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts @@ -0,0 +1,247 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash/fp'; +import { + TimelineTypeLiteralWithNull, + TimelineType, + TimelineStatus, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; +import { FrameworkRequest } from '../../../framework'; + +import { TimelineStatusActions, TimelineStatusAction } from './common'; +import { TimelineObject } from './timeline_object'; +import { + checkIsCreateFailureCases, + checkIsUpdateFailureCases, + checkIsCreateViaImportFailureCases, + checkIsUpdateViaImportFailureCases, + commonFailureChecker, +} from './failure_cases'; + +interface GivenTimelineInput { + id: string | null | undefined; + type?: TimelineTypeLiteralWithNull; + version: string | number | null | undefined; +} + +interface TimelinesStatusProps { + status: TimelineStatus | null | undefined; + title: string | null | undefined; + timelineType: TimelineTypeLiteralWithNull | undefined; + timelineInput: GivenTimelineInput; + templateTimelineInput: GivenTimelineInput; + frameworkRequest: FrameworkRequest; +} + +export class CompareTimelinesStatus { + public readonly timelineObject: TimelineObject; + public readonly templateTimelineObject: TimelineObject; + private readonly timelineType: TimelineTypeLiteral; + private readonly title: string | null; + private readonly status: TimelineStatus; + constructor({ + status = TimelineStatus.active, + title, + timelineType = TimelineType.default, + timelineInput, + templateTimelineInput, + frameworkRequest, + }: TimelinesStatusProps) { + this.timelineObject = new TimelineObject({ + id: timelineInput.id, + type: timelineInput.type ?? TimelineType.default, + version: timelineInput.version, + frameworkRequest, + }); + + this.templateTimelineObject = new TimelineObject({ + id: templateTimelineInput.id, + type: templateTimelineInput.type ?? TimelineType.template, + version: templateTimelineInput.version, + frameworkRequest, + }); + + this.timelineType = timelineType ?? TimelineType.default; + this.title = title ?? null; + this.status = status ?? TimelineStatus.active; + } + + public get isCreatable() { + return ( + this.isTitleValid && + !this.isSavedObjectVersionConflict && + ((this.timelineObject.isCreatable && !this.isHandlingTemplateTimeline) || + (this.templateTimelineObject.isCreatable && + this.timelineObject.isCreatable && + this.isHandlingTemplateTimeline)) + ); + } + + public get isCreatableViaImport() { + return ( + this.isCreatedStatusValid && + ((this.isCreatable && !this.isHandlingTemplateTimeline) || + (this.isCreatable && this.isHandlingTemplateTimeline && this.isTemplateVersionValid)) + ); + } + + private get isCreatedStatusValid() { + const obj = this.isHandlingTemplateTimeline ? this.templateTimelineObject : this.timelineObject; + + return obj.isExists + ? this.status === obj.getData?.status && this.status !== TimelineStatus.draft + : this.status !== TimelineStatus.draft; + } + + public get isUpdatable() { + return ( + this.isTitleValid && + !this.isSavedObjectVersionConflict && + ((this.timelineObject.isUpdatable && !this.isHandlingTemplateTimeline) || + (this.templateTimelineObject.isUpdatable && this.isHandlingTemplateTimeline)) + ); + } + + private get isTimelineTypeValid() { + const obj = this.isHandlingTemplateTimeline ? this.templateTimelineObject : this.timelineObject; + const existintTimelineType = obj.getData?.timelineType ?? TimelineType.default; + return obj.isExists ? this.timelineType === existintTimelineType : true; + } + + public get isUpdatableViaImport() { + return ( + this.isTimelineTypeValid && + this.isTitleValid && + this.isUpdatedTimelineStatusValid && + (this.timelineObject.isUpdatableViaImport || + (this.templateTimelineObject.isUpdatableViaImport && + this.isTemplateVersionValid && + this.isHandlingTemplateTimeline)) + ); + } + + public get isTitleValid() { + return ( + (this.status !== TimelineStatus.draft && !isEmpty(this.title)) || + this.status === TimelineStatus.draft + ); + } + + public getFailureChecker(action?: TimelineStatusAction) { + if (action === TimelineStatusActions.create) { + return checkIsCreateFailureCases; + } else if (action === TimelineStatusActions.createViaImport) { + return checkIsCreateViaImportFailureCases; + } else if (action === TimelineStatusActions.update) { + return checkIsUpdateFailureCases; + } else { + return checkIsUpdateViaImportFailureCases; + } + } + + public checkIsFailureCases(action?: TimelineStatusAction) { + const failureChecker = this.getFailureChecker(action); + const version = this.templateTimelineObject.getVersion; + const commonError = commonFailureChecker(this.status, this.title); + if (commonError != null) { + return commonError; + } + + const msg = failureChecker( + this.isHandlingTemplateTimeline, + this.status, + this.timelineType, + this.timelineObject.getVersion?.toString() ?? null, + version != null && typeof version === 'string' ? parseInt(version, 10) : version, + this.templateTimelineObject.getId, + this.timelineObject.getData, + this.templateTimelineObject.getData + ); + return msg; + } + + public get templateTimelineInput() { + return this.templateTimelineObject; + } + + public get timelineInput() { + return this.timelineObject; + } + + private getTimelines() { + return Promise.all([ + this.timelineObject.getTimeline(), + this.templateTimelineObject.getTimeline(), + ]); + } + + public get isHandlingTemplateTimeline() { + return this.timelineType === TimelineType.template; + } + + private get isSavedObjectVersionConflict() { + const version = this.timelineObject?.getVersion; + const existingVersion = this.timelineObject?.data?.version; + if (version != null && this.timelineObject.isExists) { + return version !== existingVersion; + } else if (this.timelineObject.isExists && version == null) { + return true; + } + return false; + } + + private get isTemplateVersionConflict() { + const version = this.templateTimelineObject?.getVersion; + const existingTemplateTimelineVersion = this.templateTimelineObject?.data + ?.templateTimelineVersion; + if ( + version != null && + this.templateTimelineObject.isExists && + existingTemplateTimelineVersion != null + ) { + return version <= existingTemplateTimelineVersion; + } else if (this.templateTimelineObject.isExists && version == null) { + return true; + } + return false; + } + + private get isTemplateVersionValid() { + const version = this.templateTimelineObject?.getVersion; + return typeof version === 'number' && !this.isTemplateVersionConflict; + } + + private get isUpdatedTimelineStatusValid() { + const status = this.status; + const existingStatus = this.isHandlingTemplateTimeline + ? this.templateTimelineInput.data?.status + : this.timelineInput.data?.status; + return ( + ((existingStatus == null || existingStatus === TimelineStatus.active) && + (status == null || status === TimelineStatus.active)) || + (existingStatus != null && status === existingStatus) + ); + } + + public get timelineId() { + if (this.isHandlingTemplateTimeline) { + return this.templateTimelineInput.data?.savedObjectId ?? this.templateTimelineInput.getId; + } + return this.timelineInput.data?.savedObjectId ?? this.timelineInput.getId; + } + + public get timelineVersion() { + const version = this.isHandlingTemplateTimeline + ? this.templateTimelineInput.data?.version ?? this.timelineInput.getVersion + : this.timelineInput.data?.version ?? this.timelineInput.getVersion; + return version != null ? version.toString() : null; + } + + public async init() { + await this.getTimelines(); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts index 5b2470821b6909..abe298566341c1 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts @@ -12,6 +12,7 @@ import { FrameworkRequest } from '../../../framework'; import { SavedTimeline, TimelineSavedObject } from '../../../../../common/types/timeline'; import { SavedNote } from '../../../../../common/types/timeline/note'; import { NoteResult, ResponseTimeline } from '../../../../graphql/types'; + export const CREATE_TIMELINE_ERROR_MESSAGE = 'UPDATE timeline with POST is not allowed, please use PATCH instead'; export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = @@ -20,16 +21,10 @@ export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = export const saveTimelines = ( frameworkRequest: FrameworkRequest, timeline: SavedTimeline, - timelineSavedObjectId?: string | null, - timelineVersion?: string | null -): Promise => { - return timelineLib.persistTimeline( - frameworkRequest, - timelineSavedObjectId ?? null, - timelineVersion ?? null, - timeline - ); -}; + timelineSavedObjectId: string | null = null, + timelineVersion: string | null = null +): Promise => + timelineLib.persistTimeline(frameworkRequest, timelineSavedObjectId, timelineVersion, timeline); export const savePinnedEvents = ( frameworkRequest: FrameworkRequest, @@ -72,15 +67,25 @@ export const saveNotes = ( ); }; -export const createTimelines = async ( - frameworkRequest: FrameworkRequest, - timeline: SavedTimeline, - timelineSavedObjectId?: string | null, - timelineVersion?: string | null, - pinnedEventIds?: string[] | null, - notes?: NoteResult[], - existingNoteIds?: string[] -): Promise => { +interface CreateTimelineProps { + frameworkRequest: FrameworkRequest; + timeline: SavedTimeline; + timelineSavedObjectId?: string | null; + timelineVersion?: string | null; + pinnedEventIds?: string[] | null; + notes?: NoteResult[]; + existingNoteIds?: string[]; +} + +export const createTimelines = async ({ + frameworkRequest, + timeline, + timelineSavedObjectId = null, + timelineVersion = null, + pinnedEventIds = null, + notes = [], + existingNoteIds = [], +}: CreateTimelineProps): Promise => { const responseTimeline = await saveTimelines( frameworkRequest, timeline, @@ -89,7 +94,6 @@ export const createTimelines = async ( ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; const newTimelineVersion = responseTimeline.timeline.version; - let myPromises: unknown[] = []; if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) { myPromises = [ @@ -143,8 +147,9 @@ export const getTemplateTimeline = async ( frameworkRequest, templateTimelineId ); + // eslint-disable-next-line no-empty } catch (e) { return null; } - return templateTimeline.timeline[0]; + return templateTimeline?.timeline[0] ?? null; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts index 1f02851c56b80e..23090bfc6f0bd9 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; + import { SavedObjectsClient, SavedObjectsFindOptions, @@ -16,7 +18,6 @@ import { ExportedNotes, TimelineSavedObject, ExportTimelineNotFoundError, - TimelineStatus, } from '../../../../../common/types/timeline'; import { NoteSavedObject } from '../../../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../../../common/types/timeline/pinned_event'; @@ -180,12 +181,11 @@ const getTimelinesFromObjects = async ( if (myTimeline != null) { const timelineNotes = myNotes.filter((n) => n.timelineId === timelineId); const timelinePinnedEventIds = myPinnedEventIds.filter((p) => p.timelineId === timelineId); + const exportedTimeline = omit('status', myTimeline); return [ ...acc, { - ...myTimeline, - status: - myTimeline.status === TimelineStatus.draft ? TimelineStatus.active : myTimeline.status, + ...exportedTimeline, ...getGlobalEventNotesByTimelineId(timelineNotes), pinnedEventIds: getPinnedEventsIdsByTimelineId(timelinePinnedEventIds), }, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts new file mode 100644 index 00000000000000..60ba5389280c47 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts @@ -0,0 +1,377 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash/fp'; +import { + TimelineSavedObject, + TimelineStatus, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; + +export const UPDATE_TIMELINE_ERROR_MESSAGE = + 'CREATE timeline with PATCH is not allowed, please use POST instead'; +export const UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = + "CREATE template timeline with PATCH is not allowed, please use POST instead (Given template timeline doesn't exist)"; +export const NO_MATCH_VERSION_ERROR_MESSAGE = + 'TimelineVersion conflict: The given version doesn not match with existing timeline'; +export const NO_MATCH_ID_ERROR_MESSAGE = + "Timeline id doesn't match with existing template timeline"; +export const TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE = 'Template timelineVersion conflict'; +export const CREATE_TIMELINE_ERROR_MESSAGE = + 'UPDATE timeline with POST is not allowed, please use PATCH instead'; +export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = + 'UPDATE template timeline with POST is not allowed, please use PATCH instead'; +export const EMPTY_TITLE_ERROR_MESSAGE = 'Title cannot be empty'; +export const UPDATE_STATUS_ERROR_MESSAGE = 'Update an immutable timeline is is not allowed'; +export const CREATE_TEMPLATE_TIMELINE_WITHOUT_VERSION_ERROR_MESSAGE = + 'Create template timeline without a valid templateTimelineVersion is not allowed. Please start from 1 to create a new template timeline'; +export const CREATE_WITH_INVALID_STATUS_ERROR_MESSAGE = 'Cannot create a draft timeline'; +export const NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE = 'Update status is not allowed'; +export const NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE = 'Update timelineType is not allowed'; +export const UPDAT_TIMELINE_VIA_IMPORT_NOT_ALLOWED_ERROR_MESSAGE = + 'Update timeline via import is not allowed'; + +const isUpdatingStatus = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + const obj = isHandlingTemplateTimeline ? existTemplateTimeline : existTimeline; + return obj?.status === TimelineStatus.immutable ? UPDATE_STATUS_ERROR_MESSAGE : null; +}; + +const isGivenTitleValid = (status: TimelineStatus, title: string | null | undefined) => { + return (status !== TimelineStatus.draft && !isEmpty(title)) || status === TimelineStatus.draft + ? null + : EMPTY_TITLE_ERROR_MESSAGE; +}; + +export const getImportExistingTimelineError = (id: string) => + `savedObjectId: "${id}" already exists`; + +export const commonFailureChecker = (status: TimelineStatus, title: string | null | undefined) => { + const error = [isGivenTitleValid(status, title)].filter((msg) => msg != null).join(','); + return !isEmpty(error) + ? { + body: error, + statusCode: 405, + } + : null; +}; + +const commonUpdateTemplateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline) { + if (existTimeline != null && timelineType !== existTimeline.timelineType) { + return { + body: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + statusCode: 403, + }; + } + + if (existTemplateTimeline == null && templateTimelineVersion != null) { + // template timeline !exists + // Throw error to create template timeline in patch + return { + body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if ( + existTimeline != null && + existTemplateTimeline != null && + existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId + ) { + // Throw error you can not have a no matching between your timeline and your template timeline during an update + return { + body: NO_MATCH_ID_ERROR_MESSAGE, + statusCode: 409, + }; + } + + if ( + existTemplateTimeline != null && + existTemplateTimeline.templateTimelineVersion == null && + existTemplateTimeline.version !== version + ) { + // throw error 409 conflict timeline + return { + body: NO_MATCH_VERSION_ERROR_MESSAGE, + statusCode: 409, + }; + } + } + return null; +}; + +const commonUpdateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (existTimeline == null) { + // timeline !exists + return { + body: UPDATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if (existTimeline?.version !== version) { + // throw error 409 conflict timeline + return { + body: NO_MATCH_VERSION_ERROR_MESSAGE, + statusCode: 409, + }; + } + + return null; +}; + +const commonUpdateCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline) { + return commonUpdateTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } else { + return commonUpdateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } +}; + +const createTemplateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline && existTemplateTimeline != null) { + // Throw error to create template timeline in patch + return { + body: CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } else if (isHandlingTemplateTimeline && templateTimelineVersion == null) { + return { + body: CREATE_TEMPLATE_TIMELINE_WITHOUT_VERSION_ERROR_MESSAGE, + statusCode: 403, + }; + } else { + return null; + } +}; + +export const checkIsUpdateViaImportFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (!isHandlingTemplateTimeline) { + if (existTimeline == null) { + return { body: UPDAT_TIMELINE_VIA_IMPORT_NOT_ALLOWED_ERROR_MESSAGE, statusCode: 405 }; + } else { + return { + body: getImportExistingTimelineError(existTimeline!.savedObjectId), + statusCode: 405, + }; + } + } else { + if (existTemplateTimeline != null && timelineType !== existTemplateTimeline?.timelineType) { + return { + body: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + statusCode: 403, + }; + } + const isStatusValid = + ((existTemplateTimeline?.status == null || + existTemplateTimeline?.status === TimelineStatus.active) && + (status == null || status === TimelineStatus.active)) || + (existTemplateTimeline?.status != null && status === existTemplateTimeline?.status); + + if (!isStatusValid) { + return { + body: NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, + statusCode: 405, + }; + } + + const error = commonUpdateTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + if (error) { + return error; + } + if ( + templateTimelineVersion != null && + existTemplateTimeline != null && + existTemplateTimeline.templateTimelineVersion != null && + existTemplateTimeline.templateTimelineVersion >= templateTimelineVersion + ) { + // Throw error you can not update a template timeline version with an old version + return { + body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + statusCode: 409, + }; + } + } + return null; +}; + +export const checkIsUpdateFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + const error = isUpdatingStatus( + isHandlingTemplateTimeline, + status, + existTimeline, + existTemplateTimeline + ); + if (error) { + return { + body: error, + statusCode: 403, + }; + } + return commonUpdateCases( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); +}; + +export const checkIsCreateFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (!isHandlingTemplateTimeline && existTimeline != null) { + return { + body: CREATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } else if (isHandlingTemplateTimeline) { + return createTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } else { + return null; + } +}; + +export const checkIsCreateViaImportFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (status === TimelineStatus.draft) { + return { + body: CREATE_WITH_INVALID_STATUS_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if (!isHandlingTemplateTimeline) { + if (existTimeline != null) { + return { + body: getImportExistingTimelineError(existTimeline.savedObjectId), + statusCode: 405, + }; + } + } else { + if (existTemplateTimeline != null) { + // Throw error to create template timeline in patch + return { + body: getImportExistingTimelineError(existTemplateTimeline.savedObjectId), + statusCode: 405, + }; + } + } + + return null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts new file mode 100644 index 00000000000000..9fb96b509ec3e8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + TimelineType, + TimelineTypeLiteral, + TimelineSavedObject, + TimelineStatus, +} from '../../../../../common/types/timeline'; +import { getTimeline, getTemplateTimeline } from './create_timelines'; +import { FrameworkRequest } from '../../../framework'; + +interface TimelineObjectProps { + id: string | null | undefined; + type: TimelineTypeLiteral; + version: string | number | null | undefined; + frameworkRequest: FrameworkRequest; +} + +export class TimelineObject { + public readonly id: string | null; + private type: TimelineTypeLiteral; + public readonly version: string | number | null; + private frameworkRequest: FrameworkRequest; + + public data: TimelineSavedObject | null; + + constructor({ + id = null, + type = TimelineType.default, + version = null, + frameworkRequest, + }: TimelineObjectProps) { + this.id = id; + this.type = type; + + this.version = version; + this.frameworkRequest = frameworkRequest; + this.data = null; + } + + public async getTimeline() { + this.data = + this.id != null + ? this.type === TimelineType.template + ? await getTemplateTimeline(this.frameworkRequest, this.id) + : await getTimeline(this.frameworkRequest, this.id) + : null; + + return this.data; + } + + public get getData() { + return this.data; + } + + private get isImmutable() { + return this.data?.status === TimelineStatus.immutable; + } + + public get isExists() { + return this.data != null; + } + + public get isUpdatable() { + return this.isExists && !this.isImmutable; + } + + public get isCreatable() { + return !this.isExists; + } + + public get isUpdatableViaImport() { + return this.type === TimelineType.template && this.isExists; + } + + public get getVersion() { + return this.version; + } + + public get getId() { + return this.id; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts deleted file mode 100644 index a4efa676daddc5..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TimelineSavedObject } from '../../../../../common/types/timeline'; - -export const UPDATE_TIMELINE_ERROR_MESSAGE = - 'CREATE timeline with PATCH is not allowed, please use POST instead'; -export const UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = - 'CREATE template timeline with PATCH is not allowed, please use POST instead'; -export const NO_MATCH_VERSION_ERROR_MESSAGE = - 'TimelineVersion conflict: The given version doesn not match with existing timeline'; -export const NO_MATCH_ID_ERROR_MESSAGE = - "Timeline id doesn't match with existing template timeline"; -export const TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE = 'Template timelineVersion conflict'; - -export const checkIsFailureCases = ( - isHandlingTemplateTimeline: boolean, - version: string | null, - templateTimelineVersion: number | null, - existTimeline: TimelineSavedObject | null, - existTemplateTimeline: TimelineSavedObject | null -) => { - if (!isHandlingTemplateTimeline && existTimeline == null) { - return { - body: UPDATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, - }; - } else if (isHandlingTemplateTimeline && existTemplateTimeline == null) { - // Throw error to create template timeline in patch - return { - body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, - }; - } else if ( - isHandlingTemplateTimeline && - existTimeline != null && - existTemplateTimeline != null && - existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId - ) { - // Throw error you can not have a no matching between your timeline and your template timeline during an update - return { - body: NO_MATCH_ID_ERROR_MESSAGE, - statusCode: 409, - }; - } else if (!isHandlingTemplateTimeline && existTimeline?.version !== version) { - // throw error 409 conflict timeline - return { - body: NO_MATCH_VERSION_ERROR_MESSAGE, - statusCode: 409, - }; - } else if ( - isHandlingTemplateTimeline && - existTemplateTimeline != null && - existTemplateTimeline.templateTimelineVersion == null && - existTemplateTimeline.version !== version - ) { - // throw error 409 conflict timeline - return { - body: NO_MATCH_VERSION_ERROR_MESSAGE, - statusCode: 409, - }; - } else if ( - isHandlingTemplateTimeline && - templateTimelineVersion != null && - existTemplateTimeline != null && - existTemplateTimeline.templateTimelineVersion != null && - existTemplateTimeline.templateTimelineVersion !== templateTimelineVersion - ) { - // Throw error you can not update a template timeline version with an old version - return { - body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, - statusCode: 409, - }; - } else { - return null; - } -}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index bbb11cd642c4ca..ec90fc6d8e0710 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -7,13 +7,20 @@ import { getOr } from 'lodash/fp'; import { SavedObjectsFindOptions } from '../../../../../../src/core/server'; -import { UNAUTHENTICATED_USER, disableTemplate } from '../../../common/constants'; +import { + UNAUTHENTICATED_USER, + disableTemplate, + enableElasticFilter, +} from '../../../common/constants'; import { NoteSavedObject } from '../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event'; import { SavedTimeline, TimelineSavedObject, TimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, + TemplateTimelineTypeLiteralWithNull, + TemplateTimelineType, } from '../../../common/types/timeline'; import { ResponseTimeline, @@ -38,6 +45,14 @@ interface ResponseTimelines { totalCount: number; } +interface AllTimelinesResponse extends ResponseTimelines { + defaultTimelineCount: number; + templateTimelineCount: number; + elasticTemplateTimelineCount: number; + customTemplateTimelineCount: number; + favoriteCount: number; +} + export interface ResponseTemplateTimeline { code?: Maybe; @@ -55,8 +70,10 @@ export interface Timeline { pageInfo: PageInfoTimeline | null, search: string | null, sort: SortTimeline | null, - timelineType: TimelineTypeLiteralWithNull - ) => Promise; + status: TimelineStatusLiteralWithNull, + timelineType: TimelineTypeLiteralWithNull, + templateTimelineType: TemplateTimelineTypeLiteralWithNull + ) => Promise; persistFavorite: ( request: FrameworkRequest, @@ -97,7 +114,7 @@ export const getTimelineByTemplateTimelineId = async ( }> => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, - filter: `siem-ui-timeline.attributes.templateTimelineId: ${templateTimelineId}`, + filter: `siem-ui-timeline.attributes.templateTimelineId: "${templateTimelineId}"`, }; return getAllSavedTimeline(request, options); }; @@ -106,10 +123,13 @@ export const getTimelineByTemplateTimelineId = async ( * which has no timelineType exists in the savedObject */ const getTimelineTypeFilter = ( timelineType: TimelineTypeLiteralWithNull, - includeDraft: boolean + templateTimelineType: TemplateTimelineTypeLiteralWithNull, + status: TimelineStatusLiteralWithNull ) => { const typeFilter = - timelineType === TimelineType.template + timelineType == null + ? null + : timelineType === TimelineType.template ? `siem-ui-timeline.attributes.timelineType: ${TimelineType.template}` /** Show only whose timelineType exists and equals to "template" */ : /** Show me every timeline whose timelineType is not "template". * which includes timelineType === 'default' and @@ -119,10 +139,30 @@ const getTimelineTypeFilter = ( /** Show me every timeline whose status is not "draft". * which includes status === 'active' and * those status doesn't exists */ - const draftFilter = includeDraft - ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` - : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; - return `${typeFilter} and ${draftFilter}`; + const draftFilter = + status === TimelineStatus.draft + ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` + : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; + + const immutableFilter = + status == null + ? null + : status === TimelineStatus.immutable + ? `siem-ui-timeline.attributes.status: ${TimelineStatus.immutable}` + : `not siem-ui-timeline.attributes.status: ${TimelineStatus.immutable}`; + + const templateTimelineTypeFilter = + templateTimelineType == null + ? null + : templateTimelineType === TemplateTimelineType.elastic + ? `siem-ui-timeline.attributes.createdBy: "Elsatic"` + : `not siem-ui-timeline.attributes.createdBy: "Elastic"`; + + const filters = + !disableTemplate && enableElasticFilter + ? [typeFilter, draftFilter, immutableFilter, templateTimelineTypeFilter] + : [typeFilter, draftFilter, immutableFilter]; + return filters.filter((f) => f != null).join(' and '); }; export const getAllTimeline = async ( @@ -131,8 +171,10 @@ export const getAllTimeline = async ( pageInfo: PageInfoTimeline | null, search: string | null, sort: SortTimeline | null, - timelineType: TimelineTypeLiteralWithNull -): Promise => { + status: TimelineStatusLiteralWithNull, + timelineType: TimelineTypeLiteralWithNull, + templateTimelineType: TemplateTimelineTypeLiteralWithNull +): Promise => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, perPage: pageInfo != null ? pageInfo.pageSize : undefined, @@ -144,13 +186,78 @@ export const getAllTimeline = async ( /** * CreateTemplateTimelineBtn * Remove the comment here to enable template timeline and apply the change below - * filter: getTimelineTypeFilter(timelineType, false) + * filter: getTimelineTypeFilter(timelineType, templateTimelineType, false) */ - filter: getTimelineTypeFilter(disableTemplate ? TimelineType.default : timelineType, false), + filter: getTimelineTypeFilter( + disableTemplate ? TimelineType.default : timelineType, + disableTemplate ? null : templateTimelineType, + disableTemplate ? null : status + ), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; - return getAllSavedTimeline(request, options); + + const timelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(TimelineType.default, null, TimelineStatus.active), + }; + + const templateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(TimelineType.template, null, null), + }; + + const elasticTemplateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter( + TimelineType.template, + TemplateTimelineType.elastic, + TimelineStatus.immutable + ), + }; + + const customTemplateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter( + TimelineType.template, + TemplateTimelineType.custom, + TimelineStatus.active + ), + }; + + const favoriteTimelineOptions = { + type: timelineSavedObjectType, + searchFields: ['title', 'description', 'favorite.keySearch'], + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(timelineType, null, TimelineStatus.active), + }; + + const result = await Promise.all([ + getAllSavedTimeline(request, options), + getAllSavedTimeline(request, timelineOptions), + getAllSavedTimeline(request, templateTimelineOptions), + getAllSavedTimeline(request, elasticTemplateTimelineOptions), + getAllSavedTimeline(request, customTemplateTimelineOptions), + getAllSavedTimeline(request, favoriteTimelineOptions), + ]); + + return Promise.resolve({ + ...result[0], + defaultTimelineCount: result[1].totalCount, + templateTimelineCount: result[2].totalCount, + elasticTemplateTimelineCount: result[3].totalCount, + customTemplateTimelineCount: result[4].totalCount, + favoriteCount: result[5].totalCount, + }); }; export const getDraftTimeline = async ( @@ -160,7 +267,11 @@ export const getDraftTimeline = async ( const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, perPage: 1, - filter: getTimelineTypeFilter(timelineType, true), + filter: getTimelineTypeFilter( + timelineType, + timelineType === TimelineType.template ? TemplateTimelineType.custom : null, + TimelineStatus.draft + ), sortField: 'created', sortOrder: 'desc', }; @@ -395,7 +506,6 @@ const getAllSavedTimeline = async (request: FrameworkRequest, options: SavedObje } const savedObjects = await savedObjectsClient.find(options); - const timelinesWithNotesAndPinnedEvents = await Promise.all( savedObjects.saved_objects.map(async (savedObject) => { const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject); From 40ff82d7794a84cb3faf06b8a3eb201a3da925c9 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Sat, 27 Jun 2020 02:20:29 -0400 Subject: [PATCH 07/38] [Lens] Fix broken test (#70117) --- .../lens/public/indexpattern_datasource/loader.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d8d8ebcf12de4f..e8c8c5762bb833 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -177,8 +177,7 @@ function mockClient() { } as unknown) as Pick; } -// Failing: See https://github.com/elastic/kibana/issues/70104 -describe.skip('loader', () => { +describe('loader', () => { describe('loadIndexPatterns', () => { it('should not load index patterns that are already loaded', async () => { const cache = await loadIndexPatterns({ @@ -318,7 +317,6 @@ describe.skip('loader', () => { a: sampleIndexPatterns.a, }, layers: {}, - showEmptyFields: false, }); expect(storage.set).toHaveBeenCalledWith('lens-settings', { indexPatternId: 'a', @@ -341,7 +339,6 @@ describe.skip('loader', () => { b: sampleIndexPatterns.b, }, layers: {}, - showEmptyFields: false, }); }); From 3571100bcce63ec3f338a893bd9c549007f7255c Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 29 Jun 2020 08:31:59 -0400 Subject: [PATCH 08/38] [CCR] Fix reducer function when finding missing privileges (#70158) --- .../cross_cluster_replication/register_permissions_route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts index b8eb5ae14750e3..008828d264a2b7 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts @@ -43,13 +43,13 @@ export const registerPermissionsRoute = ({ }); const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions: any, permissionName: any) => { + (permissions: string[], permissionName: string) => { if (!cluster[permissionName]) { permissions.push(permissionName); - return permissions; } + return permissions; }, - [] as any[] + [] ); return response.ok({ From 7e5cff4be988e95165513c2620d333bcf1ae893c Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 29 Jun 2020 15:17:00 +0200 Subject: [PATCH 09/38] [GS] add application result provider (#68488) * add application result provider * remove empty contracts & cache searchable apps * fix types --- ...kibana-plugin-core-public.publicappinfo.md | 3 + ...-plugin-core-public.publiclegacyappinfo.md | 2 + package.json | 1 + src/core/public/application/types.ts | 7 + src/core/public/application/utils.ts | 5 + src/core/public/public.api.md | 5 + .../global_search_providers/kibana.json | 10 + .../global_search_providers/public/index.ts | 11 + .../public/plugin.test.ts | 33 +++ .../global_search_providers/public/plugin.ts | 29 +++ .../providers/application.test.mocks.ts | 10 + .../public/providers/application.test.ts | 204 ++++++++++++++++++ .../public/providers/application.ts | 39 ++++ .../public/providers/get_app_results.test.ts | 119 ++++++++++ .../public/providers/get_app_results.ts | 58 +++++ .../public/providers/index.ts | 7 + x-pack/typings/js_levenshtein.d.ts | 10 + yarn.lock | 5 + 18 files changed, 558 insertions(+) create mode 100644 x-pack/plugins/global_search_providers/kibana.json create mode 100644 x-pack/plugins/global_search_providers/public/index.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/index.ts create mode 100644 x-pack/typings/js_levenshtein.d.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md index c70f3a97a8882f..4b3b103c92731d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md @@ -11,5 +11,8 @@ Public information about a registered [application](./kibana-plugin-core-public. ```typescript export declare type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md index cc3e9de3193cb8..051638daabd12f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md @@ -11,5 +11,7 @@ Information about a registered [legacy application](./kibana-plugin-core-public. ```typescript export declare type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; ``` diff --git a/package.json b/package.json index b1202631a0c026..6b4c8ee7858148 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,7 @@ "inline-style": "^2.0.0", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 6926b6acf24115..cd2dd99c30c116 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -269,6 +269,10 @@ export interface LegacyApp extends AppBase { */ export type PublicAppInfo = Omit & { legacy: false; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; /** @@ -278,6 +282,9 @@ export type PublicAppInfo = Omit & { */ export type PublicLegacyAppInfo = Omit & { legacy: true; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; /** diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts index 1dc9ec70590017..92d25fa468c4a5 100644 --- a/src/core/public/application/utils.ts +++ b/src/core/public/application/utils.ts @@ -120,12 +120,17 @@ export function getAppInfo(app: App | LegacyApp): PublicAppInfo | Publi const { updater$, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, legacy: true, }; } else { const { updater$, mount, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, + appRoute: app.appRoute!, legacy: false, }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d10e351f4d13ed..a65b9dd9d242a7 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1143,11 +1143,16 @@ export type PluginOpaqueId = symbol; // @public export type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; // @public export type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; // @public diff --git a/x-pack/plugins/global_search_providers/kibana.json b/x-pack/plugins/global_search_providers/kibana.json new file mode 100644 index 00000000000000..025ea2bceed2ca --- /dev/null +++ b/x-pack/plugins/global_search_providers/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "globalSearchProviders", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["globalSearch"], + "optionalPlugins": [], + "configPath": ["xpack", "global_search_providers"] +} diff --git a/x-pack/plugins/global_search_providers/public/index.ts b/x-pack/plugins/global_search_providers/public/index.ts new file mode 100644 index 00000000000000..bc66994aa393a2 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { GlobalSearchProvidersPlugin, GlobalSearchProvidersPluginSetupDeps } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> = () => + new GlobalSearchProvidersPlugin(); diff --git a/x-pack/plugins/global_search_providers/public/plugin.test.ts b/x-pack/plugins/global_search_providers/public/plugin.test.ts new file mode 100644 index 00000000000000..a2880acae440b6 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../src/core/public/mocks'; +import { globalSearchPluginMock } from '../../global_search/public/mocks'; +import { GlobalSearchProvidersPlugin } from './plugin'; + +describe('GlobalSearchProvidersPlugin', () => { + let plugin: GlobalSearchProvidersPlugin; + let globalSearchSetup: ReturnType; + + beforeEach(() => { + globalSearchSetup = globalSearchPluginMock.createSetupContract(); + plugin = new GlobalSearchProvidersPlugin(); + }); + + describe('#setup', () => { + it('registers the `application` result provider', () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { globalSearch: globalSearchSetup }); + + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledTimes(1); + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'application', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/plugin.ts b/x-pack/plugins/global_search_providers/public/plugin.ts new file mode 100644 index 00000000000000..9f18c06608b01e --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, Plugin } from 'src/core/public'; +import { GlobalSearchPluginSetup } from '../../global_search/public'; +import { createApplicationResultProvider } from './providers'; + +export interface GlobalSearchProvidersPluginSetupDeps { + globalSearch: GlobalSearchPluginSetup; +} + +export class GlobalSearchProvidersPlugin + implements Plugin<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> { + setup( + { getStartServices }: CoreSetup<{}, {}>, + { globalSearch }: GlobalSearchProvidersPluginSetupDeps + ) { + const applicationPromise = getStartServices().then(([core]) => core.application); + globalSearch.registerResultProvider(createApplicationResultProvider(applicationPromise)); + return {}; + } + + start() { + return {}; + } +} diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts new file mode 100644 index 00000000000000..4fdf8a75a4bc23 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getAppResultsMock = jest.fn(); +jest.doMock('./get_app_results', () => ({ + getAppResults: getAppResultsMock, +})); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts new file mode 100644 index 00000000000000..ca19bddb602971 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAppResultsMock } from './application.test.mocks'; + +import { of, EMPTY } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { ApplicationStart, AppNavLinkStatus, AppStatus, PublicAppInfo } from 'src/core/public'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, +} from '../../../global_search/public'; +import { applicationServiceMock } from 'src/core/public/mocks'; +import { createApplicationResultProvider } from './application'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createResult = (props: Partial): GlobalSearchProviderResult => ({ + id: 'id', + title: 'title', + type: 'application', + url: '/app/id', + score: 100, + ...props, +}); + +const createAppMap = (apps: PublicAppInfo[]): Map => { + return new Map(apps.map((app) => [app.id, app])); +}; + +const expectApp = (id: string) => expect.objectContaining({ id }); +const expectResult = expectApp; + +describe('applicationResultProvider', () => { + let application: ReturnType; + + const defaultOption: GlobalSearchProviderFindOptions = { + preference: 'pref', + maxResults: 20, + aborted$: EMPTY, + }; + + beforeEach(() => { + application = applicationServiceMock.createStartContract(); + getAppResultsMock.mockReturnValue([]); + }); + + it('has the correct id', () => { + const provider = createApplicationResultProvider(Promise.resolve(application)); + expect(provider.id).toBe('application'); + }); + + it('calls `getAppResults` with the term and the list of apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); + + it('ignores inaccessible apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('sorts the results returned by `getAppResults`', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + const results = await provider.find('term', defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); + + it('only returns the highest `maxResults` results', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find('term', options).toPromise(); + + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); + + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('|'), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); + + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('---a', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('-|'); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts new file mode 100644 index 00000000000000..e40fcef17f73c8 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { from } from 'rxjs'; +import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; +import { ApplicationStart } from 'src/core/public'; +import { GlobalSearchResultProvider } from '../../../global_search/public'; +import { getAppResults } from './get_app_results'; + +export const createApplicationResultProvider = ( + applicationPromise: Promise +): GlobalSearchResultProvider => { + const searchableApps$ = from(applicationPromise).pipe( + mergeMap((application) => application.applications$), + map((apps) => + [...apps.values()].filter( + (app) => app.status === 0 && (app.legacy === true || app.chromeless !== true) + ) + ), + shareReplay(1) + ); + + return { + id: 'application', + find: (term, { aborted$, maxResults }) => { + return searchableApps$.pipe( + takeUntil(aborted$), + take(1), + map((apps) => { + const results = getAppResults(term, [...apps.values()]); + return results.sort((a, b) => b.score - a.score).slice(0, maxResults); + }) + ); + }, + }; +}; diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts new file mode 100644 index 00000000000000..1c5a446b8e5645 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppNavLinkStatus, AppStatus, PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { appToResult, getAppResults, scoreApp } from './get_app_results'; + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createLegacyApp = (props: Partial = {}): PublicLegacyAppInfo => ({ + id: 'app1', + title: 'App 1', + appUrl: '/app/app1', + legacy: true, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + ...props, +}); + +describe('getAppResults', () => { + it('retrieves the matching results', () => { + const apps = [ + createApp({ id: 'dashboard', title: 'dashboard' }), + createApp({ id: 'visualize', title: 'visualize' }), + ]; + + const results = getAppResults('dashboard', apps); + + expect(results.length).toBe(1); + expect(results[0]).toEqual(expect.objectContaining({ id: 'dashboard', score: 100 })); + }); +}); + +describe('scoreApp', () => { + describe('when the term is included in the title', () => { + it('returns 100 if the app title is an exact match', () => { + expect(scoreApp('dashboard', createApp({ title: 'dashboard' }))).toBe(100); + expect(scoreApp('dashboard', createApp({ title: 'DASHBOARD' }))).toBe(100); + expect(scoreApp('DASHBOARD', createApp({ title: 'DASHBOARD' }))).toBe(100); + expect(scoreApp('dashBOARD', createApp({ title: 'DASHboard' }))).toBe(100); + }); + + it('returns 90 if the app title starts with the term', () => { + expect(scoreApp('dash', createApp({ title: 'dashboard' }))).toBe(90); + expect(scoreApp('DASH', createApp({ title: 'dashboard' }))).toBe(90); + }); + + it('returns 75 if the term in included in the app title', () => { + expect(scoreApp('board', createApp({ title: 'dashboard' }))).toBe(75); + expect(scoreApp('shboa', createApp({ title: 'dashboard' }))).toBe(75); + }); + }); + + describe('when the term is not included in the title', () => { + it('returns the levenshtein ratio if superior or equal to 60', () => { + expect(scoreApp('0123456789', createApp({ title: '012345' }))).toBe(60); + expect(scoreApp('--1234567-', createApp({ title: '123456789' }))).toBe(60); + }); + it('returns 0 if the levenshtein ratio is inferior to 60', () => { + expect(scoreApp('0123456789', createApp({ title: '12345' }))).toBe(0); + expect(scoreApp('1-2-3-4-5', createApp({ title: '123456789' }))).toBe(0); + }); + }); + + it('works with legacy apps', () => { + expect(scoreApp('dashboard', createLegacyApp({ title: 'dashboard' }))).toBe(100); + expect(scoreApp('dash', createLegacyApp({ title: 'dashboard' }))).toBe(90); + expect(scoreApp('board', createLegacyApp({ title: 'dashboard' }))).toBe(75); + expect(scoreApp('0123456789', createLegacyApp({ title: '012345' }))).toBe(60); + expect(scoreApp('0123456789', createLegacyApp({ title: '12345' }))).toBe(0); + }); +}); + +describe('appToResult', () => { + it('converts an app to a result', () => { + const app = createApp({ + id: 'foo', + title: 'Foo', + euiIconType: 'fooIcon', + appRoute: '/app/foo', + }); + expect(appToResult(app, 42)).toEqual({ + id: 'foo', + title: 'Foo', + type: 'application', + icon: 'fooIcon', + url: '/app/foo', + score: 42, + }); + }); + + it('converts a legacy app to a result', () => { + const app = createLegacyApp({ + id: 'legacy', + title: 'Legacy', + euiIconType: 'legacyIcon', + appUrl: '/app/legacy', + }); + expect(appToResult(app, 69)).toEqual({ + id: 'legacy', + title: 'Legacy', + type: 'application', + icon: 'legacyIcon', + url: '/app/legacy', + score: 69, + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts new file mode 100644 index 00000000000000..1a1939230105b2 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import levenshtein from 'js-levenshtein'; +import { PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { GlobalSearchProviderResult } from '../../../global_search/public'; + +export const getAppResults = ( + term: string, + apps: Array +): GlobalSearchProviderResult[] => { + return apps + .map((app) => ({ app, score: scoreApp(term, app) })) + .filter(({ score }) => score > 0) + .map(({ app, score }) => appToResult(app, score)); +}; + +export const scoreApp = (term: string, { title }: PublicAppInfo | PublicLegacyAppInfo): number => { + term = term.toLowerCase(); + title = title.toLowerCase(); + + // shortcuts to avoid calculating the distance when there is an exact match somewhere. + if (title === term) { + return 100; + } + if (title.startsWith(term)) { + return 90; + } + if (title.includes(term)) { + return 75; + } + const length = Math.max(term.length, title.length); + const distance = levenshtein(term, title); + + // maximum lev distance is length, we compute the match ratio (lower distance is better) + const ratio = Math.floor((1 - distance / length) * 100); + if (ratio >= 60) { + return ratio; + } + return 0; +}; + +export const appToResult = ( + app: PublicAppInfo | PublicLegacyAppInfo, + score: number +): GlobalSearchProviderResult => { + return { + id: app.id, + title: app.title, + type: 'application', + icon: app.euiIconType, + url: app.legacy ? app.appUrl : app.appRoute, + score, + }; +}; diff --git a/x-pack/plugins/global_search_providers/public/providers/index.ts b/x-pack/plugins/global_search_providers/public/providers/index.ts new file mode 100644 index 00000000000000..d71c30d41d46a3 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createApplicationResultProvider } from './application'; diff --git a/x-pack/typings/js_levenshtein.d.ts b/x-pack/typings/js_levenshtein.d.ts new file mode 100644 index 00000000000000..812bf24bf3dd78 --- /dev/null +++ b/x-pack/typings/js_levenshtein.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +declare module 'js-levenshtein' { + const levenshtein: (a: string, b: string) => number; + export = levenshtein; +} diff --git a/yarn.lock b/yarn.lock index 0a7899e4ac1023..8b13f3bdacb635 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19523,6 +19523,11 @@ js-levenshtein@^1.1.3: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.3.tgz#3ef627df48ec8cf24bacf05c0f184ff30ef413c5" integrity sha512-/812MXr9RBtMObviZ8gQBhHO8MOrGj8HlEE+4ccMTElNA/6I3u39u+bhny55Lk921yn44nSZFy9naNLElL5wgQ== +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-search@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.3.tgz#23a86d7e064ca53a473930edc48615b6b1c1954a" From 8e57db696aefd4342d0dd18bfaf0047787d6e861 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 29 Jun 2020 08:23:52 -0500 Subject: [PATCH 10/38] [APM] Use licensing from context (#70118) * [APM] Use licensing from context We added the usage of `featureUsage.notifyUsage` from the licensing plugin in #69455. This required us to use `getStartServices to add `licensing` to `context.plugins`. In #69838 `featureUsage` was added to `context.licensing`, so we don't need to add it to `context.plugins`. --- x-pack/plugins/apm/server/plugin.ts | 64 ++++++++----------- .../server/routes/create_api/index.test.ts | 3 +- .../plugins/apm/server/routes/service_map.ts | 5 +- x-pack/plugins/apm/server/routes/typings.ts | 3 - 4 files changed, 30 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index eb781ee0783075..deafda67b806d7 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -4,46 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import { combineLatest, Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; import { - PluginInitializerContext, - Plugin, CoreSetup, CoreStart, Logger, + Plugin, + PluginInitializerContext, } from 'src/core/server'; -import { Observable, combineLatest } from 'rxjs'; -import { map, take } from 'rxjs/operators'; -import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; -import { AlertingPlugin } from '../../alerts/server'; -import { ActionsPlugin } from '../../actions/server'; +import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; -import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { createApmApi } from './routes/create_apm_api'; -import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; -import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ActionsPlugin } from '../../actions/server'; +import { AlertingPlugin } from '../../alerts/server'; import { CloudSetup } from '../../cloud/server'; -import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; -import { - LicensingPluginSetup, - LicensingPluginStart, -} from '../../licensing/server'; -import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; -import { createApmTelemetry } from './lib/apm_telemetry'; - import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { MlPluginSetup } from '../../ml/server'; +import { ObservabilityPluginSetup } from '../../observability/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, APM_SERVICE_MAPS_FEATURE_NAME, APM_SERVICE_MAPS_LICENSE_TYPE, } from './feature'; +import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; +import { createApmTelemetry } from './lib/apm_telemetry'; +import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; +import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; +import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; +import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; +import { createApmApi } from './routes/create_apm_api'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; -import { MlPluginSetup } from '../../ml/server'; export interface APMPluginSetup { config$: Observable; @@ -135,18 +131,14 @@ export class APMPlugin implements Plugin { APM_SERVICE_MAPS_LICENSE_TYPE ); - core.getStartServices().then(([_coreStart, pluginsStart]) => { - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins: { - licensing: (pluginsStart as { licensing: LicensingPluginStart }) - .licensing, - observability: plugins.observability, - security: plugins.security, - ml: plugins.ml, - }, - }); + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + plugins: { + observability: plugins.observability, + security: plugins.security, + ml: plugins.ml, + }, }); return { diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index f5db936c00d3a7..3d3e26f680e0d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -9,7 +9,6 @@ import { CoreSetup, Logger } from 'src/core/server'; import { Params } from '../typings'; import { BehaviorSubject } from 'rxjs'; import { APMConfig } from '../..'; -import { LicensingPluginStart } from '../../../../licensing/server'; const getCoreMock = () => { const get = jest.fn(); @@ -41,7 +40,7 @@ const getCoreMock = () => { logger: ({ error: jest.fn(), } as unknown) as Logger, - plugins: { licensing: {} as LicensingPluginStart }, + plugins: {}, }, }; }; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 3937c18b3fe5e0..a3e2f708b0b22e 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -35,10 +35,7 @@ export const serviceMapRoute = createRoute(() => ({ if (!isValidPlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - - context.plugins.licensing.featureUsage.notifyUsage( - APM_SERVICE_MAPS_FEATURE_NAME - ); + context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); const setup = await setupRequest(context, request); const { diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index f30a9d18d7aeab..b1815e88d29178 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,7 +14,6 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; -import { LicensingPluginStart } from '../../../licensing/server'; import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; @@ -67,7 +66,6 @@ export type APMRequestHandlerContext< config: APMConfig; logger: Logger; plugins: { - licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; @@ -116,7 +114,6 @@ export interface ServerAPI { config$: Observable; logger: Logger; plugins: { - licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; From e91594aeb9ecdd718190c20818493e6bc0f3f128 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Mon, 29 Jun 2020 15:24:11 +0200 Subject: [PATCH 11/38] [Ingest Manager] Use DockerServers service in integration tests. (#69822) * Partially disable test files. * Use DockerServers in EPM tests. * Only run tests when DockerServers have been set up * Reenable ingest manager API integration tests * Pass new test_packages to registry container * Enable DockerServers tests in CI. * Correctly serve filetest package for file tests. * Add helper to skip test and log warning. * Reenable further file tests. * Add developer documentation about Docker in Kibana CI. * Document use of yarn test:ftr Co-authored-by: Elastic Machine --- vars/kibanaPipeline.groovy | 2 + .../dev_docs/api_integration_tests.md | 113 +++++++++++++ x-pack/scripts/functional_tests.js | 1 + x-pack/test/epm_api_integration/apis/file.ts | 151 ------------------ .../packages/epr/yamlpipeline_1.0.0.tar.gz | Bin 1996 -> 0 bytes .../packages/package/yamlpipeline_1.0.0 | 32 ---- x-pack/test/epm_api_integration/apis/list.ts | 124 -------------- x-pack/test/epm_api_integration/config.ts | 35 ---- .../apis/file.ts | 96 +++++++++++ .../apis/fixtures/package_registry_config.yml | 3 + .../filetest/0.1.0/docs/README.md | 5 + .../test_packages/filetest/0.1.0/img/logo.svg | 7 + .../img/screenshots/metricbeat_dashboard.png | Bin 0 -> 94863 bytes .../kibana/dashboard/sample_dashboard.json | 38 +++++ .../0.1.0/kibana/search/sample_search.json | 36 +++++ .../visualization/sample_visualization.json | 22 +++ .../test_packages/filetest/0.1.0/manifest.yml | 30 ++++ .../apis/ilm.ts | 0 .../apis/index.js | 2 +- .../apis/list.ts | 38 +++++ .../apis/mock_http_server.d.ts | 0 .../apis/template.ts | 0 .../ingest_manager_api_integration/config.ts | 67 ++++++++ .../ingest_manager_api_integration/helpers.ts | 15 ++ 24 files changed, 474 insertions(+), 343 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md delete mode 100644 x-pack/test/epm_api_integration/apis/file.ts delete mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz delete mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/package/yamlpipeline_1.0.0 delete mode 100644 x-pack/test/epm_api_integration/apis/list.ts delete mode 100644 x-pack/test/epm_api_integration/config.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/file.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/ilm.ts (100%) rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/index.js (90%) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/list.ts rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/mock_http_server.d.ts (100%) rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/template.ts (100%) create mode 100644 x-pack/test/ingest_manager_api_integration/config.ts create mode 100644 x-pack/test/ingest_manager_api_integration/helpers.ts diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 46a76bbb8d523d..f3fc5f84583c9c 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -21,6 +21,7 @@ def functionalTestProcess(String name, Closure closure) { def kibanaPort = "61${processNumber}1" def esPort = "61${processNumber}2" def esTransportPort = "61${processNumber}3" + def ingestManagementPackageRegistryPort = "61${processNumber}4" withEnv([ "CI_PARALLEL_PROCESS_NUMBER=${processNumber}", @@ -29,6 +30,7 @@ def functionalTestProcess(String name, Closure closure) { "TEST_KIBANA_URL=http://elastic:changeme@localhost:${kibanaPort}", "TEST_ES_URL=http://elastic:changeme@localhost:${esPort}", "TEST_ES_TRANSPORT_PORT=${esTransportPort}", + "INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=${ingestManagementPackageRegistryPort}", "IS_PIPELINE_JOB=1", "JOB=${name}", "KBN_NP_PLUGINS_BUILT=true", diff --git a/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md b/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md new file mode 100644 index 00000000000000..612d94d01a2d0b --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md @@ -0,0 +1,113 @@ +# API integration tests + +Many API integration tests for Ingest Manager trigger at some point a connection to the package registry, and retrieval of some packages. If these connections are made to a package registry deployment outside of Kibana CI, these tests can fail at any time for two reasons: +* the deployed registry is temporarily unavailable +* the packages served by the registry do not match the expectation of the code under test + +For that reason, we run a dockerized version of the package registry in Kibana CI. For this to work, our tests must run against a custom test configuration and be kept in a custom directory, `x-pack/test/ingest_manager_api_integration`. + +## How to run the tests locally + +Usually, having the test server and the test runner in two different shells is most efficient, as it is possible to keep the server running and only rerun the test runner as often as needed. To do so, in one shell in the main `kibana` directory, run: +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr:server --config x-pack/test/ingest_manager_api_integration/config.ts +``` + +In another shell in the same directory, run +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr:runner --config x-pack/test/ingest_manager_api_integration/config.ts +``` + +However, it is also possible to **alternatively** run everything in one go, again from the main `kibana` directory: +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr --config x-pack/test/ingest_manager_api_integration/config.ts +``` +Port `12345` is used as an example here, it can be anything, but the environment variable has to be present for the tests to run at all. + + +## DockerServers service setup + +We use the `DockerServers` service provided by `kbn-test`. The documentation for this functionality can be found here: +https://github.com/elastic/kibana/blob/master/packages/kbn-test/src/functional_test_runner/lib/docker_servers/README.md + +The main configuration for the `DockerServers` service for our tests can be found in `x-pack/test/ingest_manager_api_integration/config.ts`: + +### Specify the arguments to pass to `docker run`: + +``` + const dockerArgs: string[] = [ + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/package_registry_config.yml' + )}:/registry/config.yml`, + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/test_packages' + )}:/registry/packages/test-packages`, + ]; + ``` + + `-v` mounts local paths into the docker image. The first one puts a custom configuration file into the correct place in the docker container, the second one mounts a directory containing additional packages. + +### Specify the docker image to use + +``` +image: 'docker.elastic.co/package-registry/package-registry:kibana-testing-1' +``` + +This image contains the content of `docker.elastic.co/package-registry/package-registry:master` on June 26 2020. The image used here should be stable, i.e. using `master` would defeat the purpose of having a stable set of packages to be used in Kibana CI. + +### Packages available for testing + +The containerized package registry contains a set of packages which should be sufficient to run tests against all parts of Ingest Manager. The list of the packages are logged to the console when the docker container is initialized during testing, or when the container is started manually with + +``` +docker run -p 8080:8080 docker.elastic.co/package-registry/package-registry:kibana-testing-1 +``` + +Additional packages for testing certain corner cases or error conditions can be put into `x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages`. A package `filetest` has been added there as an example. + +## Some DockerServers background + +For the `DockerServers` servers to run correctly in CI, the `INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT` environment variable needs to be under control of the CI environment. The reason behind this: it is possible that several versions of our tests are run in parallel on the same worker in Jenkins, and if we used a hard-coded port number here, those tests would run into port conflicts. (This is also the case for a few other ports, and the setup happens in `vars/kibanaPipeline.groovy`). + +Also, not every developer has `docker` installed on their workstation, so it must be possible to run the testsuite as a whole without `docker`, and preferably this should be the default behaviour. Therefore, our `DockerServers` service is only enabled when `INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT` is set. This needs to be checked in every test like this: + +``` + it('fetches a .json search file', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/search/sample_search.json') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); +``` + +If the tests are skipped in this way, they are marked in the test summary as `pending` and a warning is logged: + +``` +└-: EPM Endpoints + └-> "before all" hook + └-: list + └-> "before all" hook + └-> lists all packages from the registry + └-> "before each" hook: global before each + │ warn disabling tests because DockerServers service is not enabled, set INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT to run them + └-> lists all packages from the registry + └-> "after all" hook +[...] + │ + │1 passing (233ms) + │6 pending + │ + +``` diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 6cafa3eeef08e3..29be6d826c1bc4 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -52,6 +52,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/endpoint_api_integration_no_ingest/config.ts'), require.resolve('../test/reporting_api_integration/config.js'), require.resolve('../test/functional_embedded/config.ts'), + require.resolve('../test/ingest_manager_api_integration/config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/epm_api_integration/apis/file.ts b/x-pack/test/epm_api_integration/apis/file.ts deleted file mode 100644 index 7cf07e2cd99ae4..00000000000000 --- a/x-pack/test/epm_api_integration/apis/file.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ServerMock from 'mock-http-server'; -import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; - -export default function ({ getService }: FtrProviderContext) { - describe('package file', () => { - const server = new ServerMock({ host: 'localhost', port: 6666 }); - beforeEach(() => { - server.start(() => {}); - }); - afterEach(() => { - server.stop(() => {}); - }); - it('fetches a .png screenshot image', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png', - reply: { - headers: { 'content-type': 'image/png' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'image/png') - .expect(200); - }); - - it('fetches an .svg icon image', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/img/icon.svg', - reply: { - headers: { 'content-type': 'image/svg' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/img/icon.svg') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'image/svg'); - }); - - it('fetches an auditbeat .conf rule file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches an auditbeat .yml config file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/auditbeat/config/config.yml', - reply: { - headers: { 'content-type': 'text/yaml; charset=UTF-8' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/auditbeat/config/config.yml') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'text/yaml; charset=UTF-8') - .expect(200); - }); - - it('fetches a .json kibana visualization file', async () => { - server.on({ - method: 'GET', - path: - '/package/auditd/2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches a .json kibana dashboard file', async () => { - server.on({ - method: 'GET', - path: - '/package/auditd/2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches an .json index pattern file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/kibana/index-pattern/auditbeat-*.json', - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/index-pattern/auditbeat-*.json') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches a .json search file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - }); -} diff --git a/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz b/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz deleted file mode 100644 index ca8695f111d023b24c6ebf0e5c230a6cc79dd4a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1996 zcmV;-2Q&B|iwFP&aOPbA1MOPtZyUK0_vig9SRDe%ZM>1JSD_$v4|fOjbxG0Om%=p! zYL}8&Q_D5Ub>ay6-#aAt?bRz$bPbL_AhARaXGqR)9u%MOip4Z0j7H?D=Xd??tBX^k z3m6ZF<}aZB*L?2vhvVL03?By<-QM+RJib7~lh339iBwo1bRjrbyXf}yf1`MMuKyK| z=$uI9KdsnFWM~DC27|5oAN2dF{zv0s?;7+!ydGR%pzcYe@4;_e|8p)@SWO>^kd#Lg zWK6*GBD^9KR5lJzQN^I`-_VBsnKq&r2lseEypYI1&{!EBfASFeWl3e$ivk`gOe2Y~ zVTm%HzE_hQU_};OP$DPjmhpwW^8{f8OAtIG3VR--0g234ENS4Wrx-rd2!;u)rF$^o zA)$h-NTen(5yG%kG>`;~V5u7rN`?9>3WCRW!QY{`98s94^vwSg&-=Aia~3q5{}3zK zaCN#kaJc`^&OdmBk@NrOzz+Sx`8$mb9IyXjujk_bS+Ga{74P}E)^NQ3$K!s_>Hi!! zO8+!kKwfy2(I09LN9+H(-@P6>{htG0r2l;2ei35(|33$= zuCA)dd!E`uWq?8|B?%Ph9sR%s`SI<0^tbo#-Xfiv`(7+~K&0eC>b&|231Z3ylVc+^ zr-X$Qv;qoUA=pOP>jhEMw2wSOlI}ykzn~FjDG6OfAZj|tlqCX^dnFQL*lQ!JF>hp0 zm7zzO;ptjx9E{~w=NMz9h=8qVzgQ~@eG0GQ4Z3}?hGqKi+f)Q9Lbmqr5gj-os?QU z`I3Gr@y`0-AFOrC@AZJ2Su!_dtyGR6zzmA0X~090311Q%5;2`KypYcsW<#vJvZTb; zT^rfnZUYUQTvbxFZ>t4tXMUf|KxIS`*~tG{_YM&&{#X-{e#vZb7YiQc4Tc@~(YnbB z!9{H|9x+RRLug3&akMVn3Q^fl>e{6CyRu*GcwV2}SF4Tqz;{~z|d&i|hS zyZwKpZ-*HDqyxa;^D~skg61%gSw&{}bUs0W`k2|gA1tx>UUj;c=*=6{(cdmRt##`% zCAU{k?e+Sv#@h1vv?|#~3ywqkNO8aWJaI9@`hw}BDrDOI$|N!zEhZ2)Xv9Ef+GqoV zy$rL^ld=IT5Ckg{qBpwjw*BWk%CzrZm&vPL8TmG9-}O0o^r@$M!K zczQ3Rgt1+Vy=~iOGxLaiK!3q<`7@3?m<{98{9>PFXkG$8GEK60P%b8X=jZ*ltQ? zJdHx~@f|R3-?dAkvLUQt2qV!#Eju-8O_dVhFoX~&8-~hcCY68#(&@cK@pcY6eD53{ z|Ka*2VkHI}M3^MUExOa5oOR9JFI{u5w&rGoCYdcaMbvBu;%Cvcx)2>ds}^nhZ}OEE zMt!C4tRIQkB17&eXzQ0&W=8I3_y6xQ8-I@OzJbKT89 zHJTsa9qZoWL^G1EDm1Kg zit8Jy-vq14rNg=BnOO{Wo8;x*OJ$ z+_3)k{FB4i#UB5ElDB|+{67SMVg2|20gyZYe+Hzl`u@Qe_GMrG+|SD{2B1Xttk;-A zr@3iG)r)`UHojdnoPTqb=<7@N6Uo^7`~P@Qd;dKc_gwyW7U;66rL&H)>2Zbdk%XK{GTWl3%*B>Dn0DA9mNA-=)N`iO-s8deIs~h zC`!+Nfy7pYt$RAd5!T;z;|?%&~FQ&$BsSr=hv^qKYQNu zmDpFR$3m0Ur|EU`vSJGTW%Ykyvd>~#I{lKt!z7Ew^rg9OFB&sTG)9)U7V*$B) { - const server = new ServerMock({ host: 'localhost', port: 6666 }); - beforeEach(() => { - server.start(() => {}); - }); - afterEach(() => { - server.stop(() => {}); - }); - it('lists all packages from the registry', async () => { - const searchResponse = [ - { - description: 'First integration package', - download: '/package/first-1.0.1.tar.gz', - name: 'first', - title: 'First', - type: 'integration', - version: '1.0.1', - }, - { - description: 'Second integration package', - download: '/package/second-2.0.4.tar.gz', - icons: [ - { - src: '/package/second-2.0.4/img/icon.svg', - type: 'image/svg+xml', - }, - ], - name: 'second', - title: 'Second', - type: 'integration', - version: '2.0.4', - }, - ]; - server.on({ - method: 'GET', - path: '/search', - reply: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(searchResponse), - }, - }); - - const supertest = getService('supertest'); - const fetchPackageList = async () => { - const response = await supertest - .get('/api/ingest_manager/epm/packages') - .set('kbn-xsrf', 'xxx') - .expect(200); - return response.body; - }; - - const listResponse = await fetchPackageList(); - expect(listResponse.response.length).to.be(2); - expect(listResponse.response[0]).to.eql({ ...searchResponse[0], status: 'not_installed' }); - expect(listResponse.response[1]).to.eql({ ...searchResponse[1], status: 'not_installed' }); - }); - - it('sorts the packages even if the registry sends them unsorted', async () => { - const searchResponse = [ - { - description: 'BBB integration package', - download: '/package/bbb-1.0.1.tar.gz', - name: 'bbb', - title: 'BBB', - type: 'integration', - version: '1.0.1', - }, - { - description: 'CCC integration package', - download: '/package/ccc-2.0.4.tar.gz', - name: 'ccc', - title: 'CCC', - type: 'integration', - version: '2.0.4', - }, - { - description: 'AAA integration package', - download: '/package/aaa-0.0.1.tar.gz', - name: 'aaa', - title: 'AAA', - type: 'integration', - version: '0.0.1', - }, - ]; - server.on({ - method: 'GET', - path: '/search', - reply: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(searchResponse), - }, - }); - - const supertest = getService('supertest'); - const fetchPackageList = async () => { - const response = await supertest - .get('/api/ingest_manager/epm/packages') - .set('kbn-xsrf', 'xxx') - .expect(200); - return response.body; - }; - - const listResponse = await fetchPackageList(); - - expect(listResponse.response.length).to.be(3); - expect(listResponse.response[0].name).to.eql('aaa'); - expect(listResponse.response[1].name).to.eql('bbb'); - expect(listResponse.response[2].name).to.eql('ccc'); - }); - }); -} diff --git a/x-pack/test/epm_api_integration/config.ts b/x-pack/test/epm_api_integration/config.ts deleted file mode 100644 index 6b08c7ec579559..00000000000000 --- a/x-pack/test/epm_api_integration/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); - - return { - testFiles: [require.resolve('./apis')], - servers: xPackAPITestsConfig.get('servers'), - services: { - supertest: xPackAPITestsConfig.get('services.supertest'), - es: xPackAPITestsConfig.get('services.es'), - }, - junit: { - reportName: 'X-Pack EPM API Integration Tests', - }, - - esTestCluster: { - ...xPackAPITestsConfig.get('esTestCluster'), - }, - - kbnTestServer: { - ...xPackAPITestsConfig.get('kbnTestServer'), - serverArgs: [ - ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), - '--xpack.ingestManager.epm.registryUrl=http://localhost:6666', - ], - }, - }; -} diff --git a/x-pack/test/ingest_manager_api_integration/apis/file.ts b/x-pack/test/ingest_manager_api_integration/apis/file.ts new file mode 100644 index 00000000000000..33eeda1ee274d1 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/file.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { warnAndSkipTest } from '../helpers'; + +export default function ({ getService }: FtrProviderContext) { + const log = getService('log'); + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + describe('package file', () => { + it('fetches a .png screenshot image', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/png') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches an .svg icon image', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/img/logo.svg') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/svg+xml') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json kibana visualization file', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/visualization/sample_visualization.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json kibana dashboard file', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json search file', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/search/sample_search.json') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + }); + + // Disabled for now as we don't serve prebuilt index patterns in current packages. + // it('fetches an .json index pattern file', async function () { + // if (server.enabled) { + // await supertest + // .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/index-pattern/sample-*.json') + // .set('kbn-xsrf', 'xxx') + // .expect('Content-Type', 'application/json; charset=utf-8') + // .expect(200); + // } else { + // warnAndSkipTest(this, log); + // } + // }); +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml new file mode 100644 index 00000000000000..0060e247827dae --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml @@ -0,0 +1,3 @@ + package_paths: + - /registry/packages/package-storage + - /registry/packages/test-packages \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md new file mode 100644 index 00000000000000..0d19532bae2d72 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md @@ -0,0 +1,5 @@ +# filetest + +This package contains randomly collected files from other packages to be used in API integration tests. + +It also serves as an example how to serve a package from the fixtures directory with the package registry docker container. For this, also see the `x-pack/test/ingest_manager_api_integration/config.ts` how the `test_packages` directory is mounted into the docker container, and `x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml` how to pass the directory to the registry. \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg new file mode 100644 index 00000000000000..15b49bcf28aec2 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..76d414b86c4ab447ba7334e7a4797ae7e137a4f4 GIT binary patch literal 94863 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

pKP@oJqhb%^lSm%Gaw$mvo9c$gO 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 SqSbLwN5Rw8&t;ucLK6TtSdwJ` literal 0 HcmV?d00001 diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json new file mode 100644 index 00000000000000..0370f58706a65c --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json @@ -0,0 +1,38 @@ +{ + "attributes": { + "description": "Logs Kafka integration dashboard", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"highlightAll\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false}", + "panelsJSON": "[{\"embeddableConfig\":{},\"gridData\":{\"h\":12,\"i\":\"1\",\"w\":24,\"x\":0,\"y\":0},\"panelIndex\":\"1\",\"panelRefName\":\"panel_0\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"kafka.log.class\",\"kafka.log.trace.class\",\"kafka.log.trace.full\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":12,\"i\":\"2\",\"w\":24,\"x\":24,\"y\":0},\"panelIndex\":\"2\",\"panelRefName\":\"panel_1\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"log.level\",\"kafka.log.component\",\"message\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":20,\"i\":\"3\",\"w\":48,\"x\":0,\"y\":20},\"panelIndex\":\"3\",\"panelRefName\":\"panel_2\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{},\"gridData\":{\"h\":8,\"i\":\"4\",\"w\":48,\"x\":0,\"y\":12},\"panelIndex\":\"4\",\"panelRefName\":\"panel_3\",\"version\":\"7.3.0\"}]", + "timeRestore": false, + "title": "[Logs Kafka] Overview ECS", + "version": 1 + }, + "id": "sample_dashboard", + "references": [ + { + "id": "number-of-kafka-stracktraces-by-class-ecs", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "Kafka stacktraces-ecs", + "name": "panel_1", + "type": "search" + }, + { + "id": "sample_search", + "name": "panel_2", + "type": "search" + }, + { + "id": "sample_visualization", + "name": "panel_3", + "type": "visualization" + } + ], + "type": "dashboard" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json new file mode 100644 index 00000000000000..1b34746cec89e1 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json @@ -0,0 +1,36 @@ +{ + "attributes": { + "columns": [ + "log.level", + "kafka.log.component", + "message" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":null,\"disabled\":false,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\",\"key\":\"dataset.name\",\"negate\":false,\"params\":{\"query\":\"kafka.log\",\"type\":\"phrase\"},\"type\":\"phrase\",\"value\":\"log\"},\"query\":{\"match\":{\"dataset.name\":{\"query\":\"kafka.log\",\"type\":\"phrase\"}}}}],\"highlightAll\":true,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "All logs [Logs Kafka] ECS", + "version": 1 + }, + "id": "All Kafka logs-ecs", + "references": [ + { + "id": "logs-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "logs-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "search" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json new file mode 100644 index 00000000000000..5d5162436e6de7 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json @@ -0,0 +1,22 @@ +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[]}" + }, + "savedSearchRefName": "search_0", + "title": "Log levels over time [Logs Kafka] ECS", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customLabel\":\"Log Level\",\"field\":\"log.level\",\"order\":\"desc\",\"orderBy\":\"1\",\"size\":5},\"schema\":\"group\",\"type\":\"terms\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per day\"},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Count\"},\"type\":\"value\"}]},\"title\":\"Log levels over time [Logs Kafka] ECS\",\"type\":\"histogram\"}" + }, + "id": "sample_visualization", + "references": [ + { + "id": "All Kafka logs-ecs", + "name": "search_0", + "type": "search" + } + ], + "type": "visualization" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml new file mode 100644 index 00000000000000..ec3586689becf4 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml @@ -0,0 +1,30 @@ +format_version: 1.0.0 +name: filetest +title: For File Tests +description: This is a package. +version: 0.1.0 +categories: [] +# Options are experimental, beta, ga +release: beta +# The package type. The options for now are [integration, solution], more type might be added in the future. +# The default type is integration and will be set if empty. +type: integration +license: basic +# This package can be removed +removable: true + +requirement: + elasticsearch: + versions: ">7.7.0" + kibana: + versions: ">7.7.0" + +screenshots: +- src: "/img/screenshots/metricbeat_dashboard.png" + title: "metricbeat dashboard" + size: "1855x949" + type: "image/png" +icons: + - src: "/img/logo.svg" + size: "16x16" + type: "image/svg+xml" \ No newline at end of file diff --git a/x-pack/test/epm_api_integration/apis/ilm.ts b/x-pack/test/ingest_manager_api_integration/apis/ilm.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/ilm.ts rename to x-pack/test/ingest_manager_api_integration/apis/ilm.ts diff --git a/x-pack/test/epm_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js similarity index 90% rename from x-pack/test/epm_api_integration/apis/index.js rename to x-pack/test/ingest_manager_api_integration/apis/index.js index 3dc4624d15cf42..ef8880f86078b3 100644 --- a/x-pack/test/epm_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -9,7 +9,7 @@ export default function ({ loadTestFile }) { this.tags('ciGroup7'); loadTestFile(require.resolve('./list')); loadTestFile(require.resolve('./file')); - loadTestFile(require.resolve('./template')); + //loadTestFile(require.resolve('./template')); loadTestFile(require.resolve('./ilm')); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/list.ts b/x-pack/test/ingest_manager_api_integration/apis/list.ts new file mode 100644 index 00000000000000..200358cb6f8f03 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/list.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { warnAndSkipTest } from '../helpers'; + +export default function ({ getService }: FtrProviderContext) { + const log = getService('log'); + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + // use function () {} and not () => {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('list', async function () { + it('lists all packages from the registry', async function () { + if (server.enabled) { + const fetchPackageList = async () => { + const response = await supertest + .get('/api/ingest_manager/epm/packages') + .set('kbn-xsrf', 'xxx') + .expect(200); + return response.body; + }; + const listResponse = await fetchPackageList(); + expect(listResponse.response.length).to.be(11); + } else { + warnAndSkipTest(this, log); + } + }); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/mock_http_server.d.ts b/x-pack/test/ingest_manager_api_integration/apis/mock_http_server.d.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/mock_http_server.d.ts rename to x-pack/test/ingest_manager_api_integration/apis/mock_http_server.d.ts diff --git a/x-pack/test/epm_api_integration/apis/template.ts b/x-pack/test/ingest_manager_api_integration/apis/template.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/template.ts rename to x-pack/test/ingest_manager_api_integration/apis/template.ts diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts new file mode 100644 index 00000000000000..bbef12463ed089 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { defineDockerServersConfig } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + const registryPort: string | undefined = process.env.INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT; + + // mount the config file for the package registry as well as + // the directory containing additional packages into the container + const dockerArgs: string[] = [ + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/package_registry_config.yml' + )}:/registry/config.yml`, + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/test_packages' + )}:/registry/packages/test-packages`, + ]; + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!registryPort, + image: 'docker.elastic.co/package-registry/package-registry:kibana-testing-1', + portInContainer: 8080, + port: registryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + }, + }), + services: { + supertest: xPackAPITestsConfig.get('services.supertest'), + es: xPackAPITestsConfig.get('services.es'), + }, + junit: { + reportName: 'X-Pack EPM API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + ...(registryPort + ? [`--xpack.ingestManager.epm.registryUrl=http://localhost:${registryPort}`] + : []), + ], + }, + }; +} diff --git a/x-pack/test/ingest_manager_api_integration/helpers.ts b/x-pack/test/ingest_manager_api_integration/helpers.ts new file mode 100644 index 00000000000000..121630249621be --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Context } from 'mocha'; +import { ToolingLog } from '@kbn/dev-utils'; + +export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) { + log.warning( + 'disabling tests because DockerServers service is not enabled, set INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT to run them' + ); + mochaContext.skip(); +} From 64e87cd6b5300ad229ce640ddc754decf3b9eb83 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 29 Jun 2020 15:36:59 +0200 Subject: [PATCH 12/38] [Uptime] Use ML Capabilities API to determine license type (#66921) Co-authored-by: Elastic Machine --- .../__snapshots__/license_info.test.tsx.snap | 44 ++++++++------- .../__snapshots__/ml_flyout.test.tsx.snap | 29 +++++----- .../ml/__tests__/license_info.test.tsx | 8 +++ .../monitor/ml/__tests__/ml_flyout.test.tsx | 51 +++++------------- .../components/monitor/ml/license_info.tsx | 54 ++++++++++++++++--- .../components/monitor/ml/ml_flyout.tsx | 12 +++-- .../components/monitor/ml/ml_integeration.tsx | 4 +- .../monitor_duration_container.tsx | 4 +- .../contexts/uptime_settings_context.tsx | 20 +------ .../uptime/public/state/selectors/index.ts | 2 +- 10 files changed, 122 insertions(+), 106 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap index 2ba4eda82a391c..09c58b6336871c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -26,22 +26,24 @@ Array [

In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

-
- + - Start free 14-day trial + + Start free 14-day trial + - - + +
,
In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

- - Start free 14-day trial - + + Start free 14-day trial + + diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap index 7a61eb7391a10f..5c7215edcbce74 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -17,7 +17,6 @@ exports[`ML Flyout component renders without errors 1`] = ` /> -

Here you can create a machine learning job to calculate anomaly scores on @@ -67,7 +66,7 @@ exports[`ML Flyout component renders without errors 1`] = ` > In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

- - + - Start free 14-day trial + + Start free 14-day trial + - - + +
{ + beforeEach(() => { + const spy = jest.spyOn(redux, 'useDispatch'); + spy.mockReturnValue(jest.fn()); + + const spy1 = jest.spyOn(redux, 'useSelector'); + spy1.mockReturnValue(true); + }); it('shallow renders without errors', () => { const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx index 31cdcfac9feef3..4795042ed845fd 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx @@ -9,47 +9,21 @@ import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { MLFlyoutView } from '../ml_flyout'; import { UptimeSettingsContext } from '../../../../contexts'; import { CLIENT_DEFAULTS } from '../../../../../common/constants'; -import { License } from '../../../../../../../plugins/licensing/common/license'; - -const expiredLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'platinum', - status: 'expired', - type: 'platinum', - uid: '1', - }, - features: { - ml: { - isAvailable: false, - isEnabled: false, - }, - }, -}); - -const validLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 30000, - mode: 'platinum', - status: 'active', - type: 'platinum', - uid: '2', - }, - features: { - ml: { - isAvailable: true, - isEnabled: true, - }, - }, -}); +import * as redux from 'react-redux'; describe('ML Flyout component', () => { const createJob = () => {}; const onClose = () => {}; const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS; + beforeEach(() => { + const spy = jest.spyOn(redux, 'useDispatch'); + spy.mockReturnValue(jest.fn()); + + const spy1 = jest.spyOn(redux, 'useSelector'); + spy1.mockReturnValue(true); + }); + it('renders without errors', () => { const wrapper = shallowWithIntl( { expect(wrapper).toMatchSnapshot(); }); it('shows license info if no ml available', () => { + const spy1 = jest.spyOn(redux, 'useSelector'); + + // return false value for no license + spy1.mockReturnValue(false); + const value = { - license: expiredLicense, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, @@ -88,7 +66,6 @@ describe('ML Flyout component', () => { it('able to create job if valid license is available', () => { const value = { - license: validLicense, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx index e37ec4cc4715d4..2461875d502b77 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx @@ -3,13 +3,48 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; import { UptimeSettingsContext } from '../../../contexts'; import * as labels from './translations'; +import { getMLCapabilitiesAction } from '../../../state/actions'; +import { hasMLFeatureSelector } from '../../../state/selectors'; export const ShowLicenseInfo = () => { const { basePath } = useContext(UptimeSettingsContext); + const [loading, setLoading] = useState(false); + const hasMlFeature = useSelector(hasMLFeatureSelector); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getMLCapabilitiesAction.get()); + }, [dispatch]); + + useEffect(() => { + let retryInterval: any; + if (loading) { + retryInterval = setInterval(() => { + dispatch(getMLCapabilitiesAction.get()); + }, 5000); + } else { + clearInterval(retryInterval); + } + + return () => { + clearInterval(retryInterval); + }; + }, [dispatch, loading]); + + useEffect(() => { + setLoading(false); + }, [hasMlFeature]); + + const startLicenseTrial = () => { + setLoading(true); + }; + return ( <> { iconType="help" >

{labels.START_TRAIL_DESC}

- - {labels.START_TRAIL} - + {}}> + + {labels.START_TRAIL} + +
diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx index 8c3f814e841f7a..3e60f09452587a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx @@ -20,9 +20,11 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; import * as labels from './translations'; import { UptimeSettingsContext } from '../../../contexts'; import { ShowLicenseInfo } from './license_info'; +import { hasMLFeatureSelector } from '../../../state/selectors'; interface Props { isCreatingJob: boolean; @@ -32,11 +34,11 @@ interface Props { } export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateMLJob }: Props) { - const { basePath, license } = useContext(UptimeSettingsContext); + const { basePath } = useContext(UptimeSettingsContext); - const isLoadingMLJob = false; + const hasMlFeature = useSelector(hasMLFeatureSelector); - const hasPlatinumLicense = license?.getFeature('ml')?.isAvailable; + const isLoadingMLJob = false; return ( @@ -47,7 +49,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM - {!hasPlatinumLicense && } + {!hasMlFeature && }

{labels.CREAT_ML_JOB_DESC}

@@ -80,7 +82,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM onClick={() => onClickCreate()} fill isLoading={isCreatingJob} - disabled={isCreatingJob || isLoadingMLJob || !hasPlatinumLicense || !canCreateMLJob} + disabled={isCreatingJob || isLoadingMLJob || !hasMlFeature || !canCreateMLJob} > {labels.CREATE_NEW_JOB} diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx index e66808f76d24a2..1de19dda3b88f4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx @@ -8,7 +8,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { MachineLearningFlyout } from './ml_flyout_container'; import { - hasMLFeatureAvailable, + hasMLFeatureSelector, hasMLJobSelector, isMLJobDeletedSelector, isMLJobDeletingSelector, @@ -35,7 +35,7 @@ export const MLIntegrationComponent = () => { const dispatch = useDispatch(); - const isMLAvailable = useSelector(hasMLFeatureAvailable); + const isMLAvailable = useSelector(hasMLFeatureSelector); const deleteMLJob = () => dispatch(deleteMLJobAction.get({ monitorId: monitorId as string })); const isMLJobDeleting = useSelector(isMLJobDeletingSelector); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index b586c1241290bc..df8ceed76b7968 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -14,7 +14,7 @@ import { } from '../../../state/actions'; import { anomaliesSelector, - hasMLFeatureAvailable, + hasMLFeatureSelector, hasMLJobSelector, selectDurationLines, } from '../../../state/selectors'; @@ -34,7 +34,7 @@ export const MonitorDuration: React.FC = ({ monitorId }) => { const { durationLines, loading } = useSelector(selectDurationLines); - const isMLAvailable = useSelector(hasMLFeatureAvailable); + const isMLAvailable = useSelector(hasMLFeatureSelector); const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector); diff --git a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx index 4fabf3f2ed4972..142c6e17c5fd90 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -9,11 +9,9 @@ import { UptimeAppProps } from '../uptime_app'; import { CLIENT_DEFAULTS, CONTEXT_DEFAULTS } from '../../common/constants'; import { CommonlyUsedRange } from '../components/common/uptime_date_picker'; import { useGetUrlParams } from '../hooks'; -import { ILicense } from '../../../../plugins/licensing/common/types'; export interface UptimeSettingsContextValues { basePath: string; - license?: ILicense | null; dateRangeStart: string; dateRangeEnd: string; isApmAvailable: boolean; @@ -41,27 +39,12 @@ const defaultContext: UptimeSettingsContextValues = { export const UptimeSettingsContext = createContext(defaultContext); export const UptimeSettingsContextProvider: React.FC = ({ children, ...props }) => { - const { - basePath, - isApmAvailable, - isInfraAvailable, - isLogsAvailable, - commonlyUsedRanges, - plugins, - } = props; + const { basePath, isApmAvailable, isInfraAvailable, isLogsAvailable, commonlyUsedRanges } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); - let license: ILicense | null = null; - - // @ts-ignore - plugins.licensing.license$.subscribe((licenseItem: ILicense) => { - license = licenseItem; - }); - const value = useMemo(() => { return { - license, basePath, isApmAvailable, isInfraAvailable, @@ -71,7 +54,6 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ - license, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index d08db2ccf5f2d2..4c2b671203f0ad 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -36,7 +36,7 @@ export const snapshotDataSelector = ({ snapshot }: AppState) => snapshot; const mlCapabilitiesSelector = (state: AppState) => state.ml.mlCapabilities.data; -export const hasMLFeatureAvailable = createSelector( +export const hasMLFeatureSelector = createSelector( mlCapabilitiesSelector, (mlCapabilities) => mlCapabilities?.isPlatinumOrTrialLicense && mlCapabilities?.mlFeatureEnabledInSpace From 81022a320660fc9b40008e74cde91d5f3134fbb3 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Mon, 29 Jun 2020 10:01:59 -0400 Subject: [PATCH 13/38] [Ingest Manager] rollover data stream when index template mappings are not compatible (#69180) * rollover data stream when index template mappings are not compatible * update error messages Co-authored-by: Elastic Machine --- .../ingest_manager/common/types/models/epm.ts | 2 +- .../epm/elasticsearch/template/template.ts | 83 ++++++++++--------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index 599165d2bfd981..01cbdbb0ea0314 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -273,7 +273,7 @@ export interface IndexTemplate { index_patterns: string[]; template: { settings: any; - mappings: object; + mappings: any; aliases: object; }; data_stream: { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index b7760a9032aca8..9e8f327d520e3b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -330,11 +330,15 @@ const getIndices = async ( template: TemplateRef ): Promise => { const { templateName, indexTemplate } = template; - const res = await callCluster('search', getIndexQuery(templateName)); - const indices: any[] = res?.aggregations?.index.buckets; - if (indices) { - return indices.map((index) => ({ - indexName: index.key, + // Until ES provides a way to update mappings of a data stream + // get the last index of the data stream, which is the current write index + const res = await callCluster('transport.request', { + method: 'GET', + path: `/_data_stream/${templateName}-*`, + }); + if (res.length) { + return res.map((datastream: any) => ({ + indexName: datastream.indices[datastream.indices.length - 1].index_name, indexTemplate, })); } @@ -359,18 +363,40 @@ const updateExistingIndex = async ({ indexTemplate: IndexTemplate; }) => { const { settings, mappings } = indexTemplate.template; + + // for now, remove from object so as not to update stream or dataset properties of the index until type and name + // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue + // to skip updating and assume the value in the index mapping is correct + delete mappings.properties.stream; + delete mappings.properties.dataset; + + // get the dataset values from the index template to compose data stream name + const indexMappings = await getIndexMappings(indexName, callCluster); + const dataset = indexMappings[indexName].mappings.properties.dataset.properties; + if (!dataset.type.value || !dataset.name.value || !dataset.namespace.value) + throw new Error(`dataset values are missing from the index template ${indexName}`); + const dataStreamName = `${dataset.type.value}-${dataset.name.value}-${dataset.namespace.value}`; + // try to update the mappings first - // for now we assume updates are compatible try { await callCluster('indices.putMapping', { index: indexName, body: mappings, }); + // if update fails, rollover data stream } catch (err) { - throw new Error('incompatible mappings update'); + try { + const path = `/${dataStreamName}/_rollover`; + await callCluster('transport.request', { + method: 'POST', + path, + }); + } catch (error) { + throw new Error(`cannot rollover data stream ${dataStreamName}`); + } } // update settings after mappings was successful to ensure - // pointing to theme new pipeline is safe + // pointing to the new pipeline is safe // for now, only update the pipeline if (!settings.index.default_pipeline) return; try { @@ -379,36 +405,17 @@ const updateExistingIndex = async ({ body: { index: { default_pipeline: settings.index.default_pipeline } }, }); } catch (err) { - throw new Error('incompatible settings update'); + throw new Error(`could not update index template settings for ${indexName}`); } }; -const getIndexQuery = (templateName: string) => ({ - index: `${templateName}-*`, - size: 0, - body: { - query: { - bool: { - must: [ - { - exists: { - field: 'dataset.namespace', - }, - }, - { - exists: { - field: 'dataset.name', - }, - }, - ], - }, - }, - aggs: { - index: { - terms: { - field: '_index', - }, - }, - }, - }, -}); +const getIndexMappings = async (indexName: string, callCluster: CallESAsCurrentUser) => { + try { + const indexMappings = await callCluster('indices.getMapping', { + index: indexName, + }); + return indexMappings; + } catch (err) { + throw new Error(`could not get mapping from ${indexName}`); + } +}; From dbdc3cd01a6f0444ca010e59b7696944ec8ce3f7 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 29 Jun 2020 16:17:32 +0200 Subject: [PATCH 14/38] [APM] Run API tests as restricted user (#70050) --- x-pack/plugins/apm/readme.md | 25 ++++- .../basic/tests/agent_configuration.ts | 11 +- .../basic/tests/annotations.ts | 6 +- .../basic/tests/custom_link.ts | 11 +- .../basic/tests/feature_controls.ts | 2 +- .../common/authentication.ts | 102 ++++++++++++++++++ .../test/apm_api_integration/common/config.ts | 42 +++++++- .../common/ftr_provider_context.ts | 14 ++- .../trial/tests/annotations.ts | 9 +- 9 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 x-pack/test/apm_api_integration/common/authentication.ts diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index cb694712d7c97b..778b1f2ad2d91b 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -80,19 +80,38 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### API integration tests +Our tests are separated in two suites: one suite runs with a basic license, and the other +with a trial license (the equivalent of gold+). This requires separate test servers and test runs. + **Start server** +Basic: + +``` +node scripts/functional_tests_server --config x-pack/test/apm_api_integration/basic/config.ts +``` + +Trial: + ``` -node scripts/functional_tests_server --config x-pack/test/api_integration/config.ts +node scripts/functional_tests_server --config x-pack/test/apm_api_integration/trial/config.ts ``` **Run tests** +Basic: + +``` +node scripts/functional_test_runner --config x-pack/test/apm_api_integration/basic/config.ts +``` + +Trial: + ``` -node scripts/functional_test_runner --config x-pack/test/api_integration/config.ts --grep='APM specs' +node scripts/functional_test_runner --config x-pack/test/apm_api_integration/trial/config.ts ``` -APM tests are located in `x-pack/test/api_integration/apis/apm`. +APM tests are located in `x-pack/test/apm_api_integration`. For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### Linting diff --git a/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts b/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts index f6750a8eca24e7..9f39da2037f8ea 100644 --- a/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts +++ b/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts @@ -10,11 +10,12 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function agentConfigurationTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmWriteUser'); const log = getService('log'); function searchConfigurations(configuration: any) { - return supertest + return supertestRead .post(`/api/apm/settings/agent-configuration/search`) .send(configuration) .set('kbn-xsrf', 'foo'); @@ -22,7 +23,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function createConfiguration(config: AgentConfigurationIntake) { log.debug('creating configuration', config.service); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/agent-configuration`) .send(config) .set('kbn-xsrf', 'foo'); @@ -34,7 +35,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function updateConfiguration(config: AgentConfigurationIntake) { log.debug('updating configuration', config.service); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/agent-configuration?overwrite=true`) .send(config) .set('kbn-xsrf', 'foo'); @@ -46,7 +47,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function deleteConfiguration({ service }: AgentConfigurationIntake) { log.debug('deleting configuration', service); - const res = await supertest + const res = await supertestWrite .delete(`/api/apm/settings/agent-configuration`) .send({ service }) .set('kbn-xsrf', 'foo'); diff --git a/x-pack/test/apm_api_integration/basic/tests/annotations.ts b/x-pack/test/apm_api_integration/basic/tests/annotations.ts index d4b4892eaf91cd..c522ebcfb5c65e 100644 --- a/x-pack/test/apm_api_integration/basic/tests/annotations.ts +++ b/x-pack/test/apm_api_integration/basic/tests/annotations.ts @@ -10,15 +10,15 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function annotationApiTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestWrite = getService('supertestAsApmAnnotationsWriteUser'); function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) { switch (method.toLowerCase()) { case 'post': - return supertest.post(url).send(data).set('kbn-xsrf', 'foo'); + return supertestWrite.post(url).send(data).set('kbn-xsrf', 'foo'); default: - throw new Error(`Unsupported methoed ${method}`); + throw new Error(`Unsupported method ${method}`); } } diff --git a/x-pack/test/apm_api_integration/basic/tests/custom_link.ts b/x-pack/test/apm_api_integration/basic/tests/custom_link.ts index 910c4797f39b76..77fdc83523ca64 100644 --- a/x-pack/test/apm_api_integration/basic/tests/custom_link.ts +++ b/x-pack/test/apm_api_integration/basic/tests/custom_link.ts @@ -10,7 +10,8 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function customLinksTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmWriteUser'); const log = getService('log'); function searchCustomLinks(filters?: any) { @@ -18,12 +19,12 @@ export default function customLinksTests({ getService }: FtrProviderContext) { pathname: `/api/apm/settings/custom_links`, query: filters, }); - return supertest.get(path).set('kbn-xsrf', 'foo'); + return supertestRead.get(path).set('kbn-xsrf', 'foo'); } async function createCustomLink(customLink: CustomLink) { log.debug('creating configuration', customLink); - const res = await supertest + const res = await supertestWrite .post(`/api/apm/settings/custom_links`) .send(customLink) .set('kbn-xsrf', 'foo'); @@ -35,7 +36,7 @@ export default function customLinksTests({ getService }: FtrProviderContext) { async function updateCustomLink(id: string, customLink: CustomLink) { log.debug('updating configuration', id, customLink); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/custom_links/${id}`) .send(customLink) .set('kbn-xsrf', 'foo'); @@ -47,7 +48,7 @@ export default function customLinksTests({ getService }: FtrProviderContext) { async function deleteCustomLink(id: string) { log.debug('deleting configuration', id); - const res = await supertest + const res = await supertestWrite .delete(`/api/apm/settings/custom_links/${id}`) .set('kbn-xsrf', 'foo'); diff --git a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts index f3647c65106c92..42cbef69abbec9 100644 --- a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function featureControlsTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertest = getService('supertestAsApmWriteUser'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); diff --git a/x-pack/test/apm_api_integration/common/authentication.ts b/x-pack/test/apm_api_integration/common/authentication.ts new file mode 100644 index 00000000000000..9c34b4791114a4 --- /dev/null +++ b/x-pack/test/apm_api_integration/common/authentication.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PromiseReturnType } from '../../../plugins/apm/typings/common'; +import { SecurityServiceProvider } from '../../../../test/common/services/security'; + +type SecurityService = PromiseReturnType; + +export enum ApmUser { + apmReadUser = 'apm_read_user', + apmWriteUser = 'apm_write_user', + apmAnnotationsWriteUser = 'apm_annotations_write_user', +} + +const roles = { + [ApmUser.apmReadUser]: { + elasticsearch: { + cluster: [], + indices: [ + { names: ['observability-annotations'], privileges: ['read', 'view_index_metadata'] }, + ], + }, + kibana: [ + { + base: [], + feature: { + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, + [ApmUser.apmWriteUser]: { + elasticsearch: { + cluster: [], + indices: [ + { names: ['observability-annotations'], privileges: ['read', 'view_index_metadata'] }, + ], + }, + kibana: [ + { + base: [], + feature: { + apm: ['all'], + }, + spaces: ['*'], + }, + ], + }, + [ApmUser.apmAnnotationsWriteUser]: { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['observability-annotations'], + privileges: [ + 'read', + 'view_index_metadata', + 'index', + 'manage', + 'create_index', + 'create_doc', + ], + }, + ], + }, + }, +}; + +const users = { + [ApmUser.apmReadUser]: { + roles: ['apm_user', ApmUser.apmReadUser], + }, + [ApmUser.apmWriteUser]: { + roles: ['apm_user', ApmUser.apmWriteUser], + }, + [ApmUser.apmAnnotationsWriteUser]: { + roles: ['apm_user', ApmUser.apmWriteUser, ApmUser.apmAnnotationsWriteUser], + }, +}; + +export async function createApmUser(security: SecurityService, apmUser: ApmUser) { + const role = roles[apmUser]; + const user = users[apmUser]; + + if (!role || !user) { + throw new Error(`No configuration found for ${apmUser}`); + } + + await security.role.create(apmUser, role); + + await security.user.create(apmUser, { + full_name: apmUser, + password: APM_TEST_PASSWORD, + roles: user.roles, + }); +} + +export const APM_TEST_PASSWORD = 'changeme'; diff --git a/x-pack/test/apm_api_integration/common/config.ts b/x-pack/test/apm_api_integration/common/config.ts index 83dc597829a3cd..e4dc2a78ae0189 100644 --- a/x-pack/test/apm_api_integration/common/config.ts +++ b/x-pack/test/apm_api_integration/common/config.ts @@ -5,6 +5,11 @@ */ import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import supertestAsPromised from 'supertest-as-promised'; +import { format, UrlObject } from 'url'; +import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context'; +import { PromiseReturnType } from '../../../plugins/apm/typings/common'; +import { createApmUser, APM_TEST_PASSWORD, ApmUser } from './authentication'; interface Settings { license: 'basic' | 'trial'; @@ -12,6 +17,22 @@ interface Settings { name: string; } +const supertestAsApmUser = (kibanaServer: UrlObject, apmUser: ApmUser) => async ( + context: InheritedFtrProviderContext +) => { + const security = context.getService('security'); + await security.init(); + + await createApmUser(security, apmUser); + + const url = format({ + ...kibanaServer, + auth: `${apmUser}:${APM_TEST_PASSWORD}`, + }); + + return supertestAsPromised(url); +}; + export function createTestConfig(settings: Settings) { const { testFiles, license, name } = settings; @@ -20,14 +41,27 @@ export function createTestConfig(settings: Settings) { require.resolve('../../api_integration/config.ts') ); + const services = xPackAPITestsConfig.get('services') as InheritedServices; + const servers = xPackAPITestsConfig.get('servers'); + + const supertestAsApmReadUser = supertestAsApmUser(servers.kibana, ApmUser.apmReadUser); + return { testFiles, - servers: xPackAPITestsConfig.get('servers'), - services: xPackAPITestsConfig.get('services'), + servers, + services: { + ...services, + supertest: supertestAsApmReadUser, + supertestAsApmReadUser, + supertestAsApmWriteUser: supertestAsApmUser(servers.kibana, ApmUser.apmWriteUser), + supertestAsApmAnnotationsWriteUser: supertestAsApmUser( + servers.kibana, + ApmUser.apmAnnotationsWriteUser + ), + }, junit: { reportName: name, }, - esTestCluster: { ...xPackAPITestsConfig.get('esTestCluster'), license, @@ -36,3 +70,5 @@ export function createTestConfig(settings: Settings) { }; }; } + +export type ApmServices = PromiseReturnType>['services']; diff --git a/x-pack/test/apm_api_integration/common/ftr_provider_context.ts b/x-pack/test/apm_api_integration/common/ftr_provider_context.ts index 90600816d17114..aee3d556605aa6 100644 --- a/x-pack/test/apm_api_integration/common/ftr_provider_context.ts +++ b/x-pack/test/apm_api_integration/common/ftr_provider_context.ts @@ -4,4 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -export { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { FtrProviderContext as InheritedFtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { ApmServices } from './config'; + +export type InheritedServices = InheritedFtrProviderContext extends GenericFtrProviderContext< + infer TServices, + {} +> + ? TServices + : {}; + +export { InheritedFtrProviderContext }; +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/apm_api_integration/trial/tests/annotations.ts b/x-pack/test/apm_api_integration/trial/tests/annotations.ts index 0913d0c4b90bb6..d5b6b8342e5ab2 100644 --- a/x-pack/test/apm_api_integration/trial/tests/annotations.ts +++ b/x-pack/test/apm_api_integration/trial/tests/annotations.ts @@ -13,7 +13,8 @@ const DEFAULT_INDEX_NAME = 'observability-annotations'; // eslint-disable-next-line import/no-default-export export default function annotationApiTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmAnnotationsWriteUser'); const es = getService('es'); function expectContainsObj(source: JsonObject, expected: JsonObject) { @@ -30,13 +31,13 @@ export default function annotationApiTests({ getService }: FtrProviderContext) { function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) { switch (method.toLowerCase()) { case 'get': - return supertest.get(url).set('kbn-xsrf', 'foo'); + return supertestRead.get(url).set('kbn-xsrf', 'foo'); case 'post': - return supertest.post(url).send(data).set('kbn-xsrf', 'foo'); + return supertestWrite.post(url).send(data).set('kbn-xsrf', 'foo'); default: - throw new Error(`Unsupported methoed ${method}`); + throw new Error(`Unsupported method ${method}`); } } From 19bda1fceeca564d5bae73b3a78276870e25f220 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 29 Jun 2020 17:21:49 +0300 Subject: [PATCH 15/38] Reactify visualize app (#67848) * Reactify visualize app * Fix typescript failures after merging master * Make sure refresh button works * Subscribe filter manager fetches * Use redirect to landing page * Update savedSearch type * Add check for TSVB is loaded * Fix comments * Fix uiState persistence on vis load * Remove extra div around TableListView * Update DTS selectors * Add error handling for embeddable * Remove extra argument from useEditorUpdates effect * Update comments, fix typos * Remove extra div wrapper * Apply design suggestions * Revert accidental config changes * Apply navigating to dashboard * Apply redirect legacy urls * Apply incoming changes * Apply incoming changes Co-authored-by: Elastic Machine --- .../public/input_control_vis_type.ts | 2 - src/plugins/kibana_utils/common/index.ts | 1 - src/plugins/kibana_utils/public/index.ts | 1 - .../public/top_nav_menu/top_nav_menu_data.tsx | 4 +- .../saved_object/saved_object_loader.ts | 11 +- src/plugins/saved_objects/public/types.ts | 1 + .../public/components/sidebar/sidebar.tsx | 16 +- .../components/sidebar/sidebar_title.tsx | 6 +- .../public/default_editor.tsx | 3 +- .../application/components/vis_editor.js | 3 +- .../components/vis_editor_visualization.js | 2 + .../public/metrics_type.ts | 2 - src/plugins/vis_type_vega/public/vega_type.ts | 2 - src/plugins/visualizations/public/index.ts | 1 + .../public/saved_visualizations/_saved_vis.ts | 1 + .../saved_visualizations.ts | 2 +- src/plugins/visualizations/public/types.ts | 1 + src/plugins/visualizations/public/vis.ts | 17 +- .../public/vis_types/base_vis_type.ts | 9 +- .../vis_types/vis_type_alias_registry.ts | 1 + src/plugins/visualize/kibana.json | 1 - .../visualize/public/application/_app.scss | 5 - .../application/{index.scss => app.scss} | 8 +- .../visualize/public/application/app.tsx | 63 ++ .../public/application/application.ts | 111 --- .../experimental_vis_info.tsx} | 43 +- .../{editor/lib => components}/index.ts | 6 +- .../visualize_editor.scss} | 0 .../components/visualize_editor.tsx | 115 +++ .../visualize_listing.scss} | 0 .../components/visualize_listing.tsx | 154 ++++ .../components/visualize_no_match.tsx | 77 ++ .../components/visualize_top_nav.tsx | 178 ++++ .../public/application/editor/_index.scss | 1 - .../public/application/editor/editor.html | 104 --- .../public/application/editor/editor.js | 763 ------------------ .../application/editor/lib/make_stateful.ts | 58 -- .../application/editor/visualization.js | 61 -- .../editor/visualization_editor.js | 71 -- .../visualize/public/application/index.tsx | 51 ++ .../public/application/legacy_app.js | 261 ------ .../public/application/listing/_index.scss | 1 - .../listing/visualize_listing.html | 13 - .../application/listing/visualize_listing.js | 174 ---- .../listing/visualize_listing_table.js | 233 ------ .../visualize/public/application/types.ts | 80 +- .../application/{ => utils}/breadcrumbs.ts | 28 +- .../create_visualize_app_state.ts} | 31 +- .../application/utils/get_table_columns.tsx | 162 ++++ .../application/utils/get_top_nav_config.tsx | 303 +++++++ .../utils/get_visualization_instance.ts | 90 +++ .../public/application/utils/index.ts} | 14 +- .../lib => utils}/migrate_app_state.ts | 4 +- .../public/application/utils/use/index.ts} | 12 +- .../use/use_chrome_visibility.ts} | 24 +- .../utils/use/use_editor_updates.ts | 182 +++++ .../utils/use/use_linked_search_updates.ts | 74 ++ .../utils/use/use_saved_vis_instance.ts | 199 +++++ .../utils/use/use_visualize_app_state.tsx | 120 +++ .../public/application/utils/utils.ts | 71 ++ .../public/application/visualize_constants.ts | 6 +- src/plugins/visualize/public/index.ts | 2 +- .../visualize/public/kibana_services.ts | 80 -- src/plugins/visualize/public/plugin.ts | 52 +- .../functional/apps/visualize/_shared_item.js | 2 +- .../page_objects/visual_builder_page.ts | 2 + .../functional/page_objects/visualize_page.ts | 10 +- .../common/layouts/preserve_layout.css | 5 +- .../export_types/common/layouts/print.css | 5 +- .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 5 - .../feature_controls/visualize_security.ts | 6 +- .../apps/visualize/hybrid_visualization.ts | 2 +- 73 files changed, 2092 insertions(+), 2123 deletions(-) delete mode 100644 src/plugins/visualize/public/application/_app.scss rename src/plugins/visualize/public/application/{index.scss => app.scss} (67%) create mode 100644 src/plugins/visualize/public/application/app.tsx delete mode 100644 src/plugins/visualize/public/application/application.ts rename src/plugins/visualize/public/application/{help_menu/help_menu_util.js => components/experimental_vis_info.tsx} (50%) rename src/plugins/visualize/public/application/{editor/lib => components}/index.ts (80%) rename src/plugins/visualize/public/application/{editor/_editor.scss => components/visualize_editor.scss} (100%) create mode 100644 src/plugins/visualize/public/application/components/visualize_editor.tsx rename src/plugins/visualize/public/application/{listing/_listing.scss => components/visualize_listing.scss} (100%) create mode 100644 src/plugins/visualize/public/application/components/visualize_listing.tsx create mode 100644 src/plugins/visualize/public/application/components/visualize_no_match.tsx create mode 100644 src/plugins/visualize/public/application/components/visualize_top_nav.tsx delete mode 100644 src/plugins/visualize/public/application/editor/_index.scss delete mode 100644 src/plugins/visualize/public/application/editor/editor.html delete mode 100644 src/plugins/visualize/public/application/editor/editor.js delete mode 100644 src/plugins/visualize/public/application/editor/lib/make_stateful.ts delete mode 100644 src/plugins/visualize/public/application/editor/visualization.js delete mode 100644 src/plugins/visualize/public/application/editor/visualization_editor.js create mode 100644 src/plugins/visualize/public/application/index.tsx delete mode 100644 src/plugins/visualize/public/application/legacy_app.js delete mode 100644 src/plugins/visualize/public/application/listing/_index.scss delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing.html delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing.js delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing_table.js rename src/plugins/visualize/public/application/{ => utils}/breadcrumbs.ts (69%) rename src/plugins/visualize/public/application/{editor/lib/visualize_app_state.ts => utils/create_visualize_app_state.ts} (86%) create mode 100644 src/plugins/visualize/public/application/utils/get_table_columns.tsx create mode 100644 src/plugins/visualize/public/application/utils/get_top_nav_config.tsx create mode 100644 src/plugins/visualize/public/application/utils/get_visualization_instance.ts rename src/plugins/{kibana_utils/common/default_feedback_message.ts => visualize/public/application/utils/index.ts} (69%) rename src/plugins/visualize/public/application/{editor/lib => utils}/migrate_app_state.ts (94%) rename src/plugins/{kibana_utils/common/default_feedback_message.test.ts => visualize/public/application/utils/use/index.ts} (68%) rename src/plugins/visualize/public/application/{visualize_app.ts => utils/use/use_chrome_visibility.ts} (65%) create mode 100644 src/plugins/visualize/public/application/utils/use/use_editor_updates.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx create mode 100644 src/plugins/visualize/public/application/utils/utils.ts delete mode 100644 src/plugins/visualize/public/kibana_services.ts diff --git a/src/plugins/input_control_vis/public/input_control_vis_type.ts b/src/plugins/input_control_vis/public/input_control_vis_type.ts index 8114dbf110f8b3..2af53ea4d28e83 100644 --- a/src/plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/plugins/input_control_vis/public/input_control_vis_type.ts @@ -23,7 +23,6 @@ import { createInputControlVisController } from './vis_controller'; import { getControlsTab } from './components/editor/controls_tab'; import { OptionsTab } from './components/editor/options_tab'; import { InputControlVisDependencies } from './plugin'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; export function createInputControlVisTypeDefinition(deps: InputControlVisDependencies) { const InputControlVisController = createInputControlVisController(deps); @@ -39,7 +38,6 @@ export function createInputControlVisTypeDefinition(deps: InputControlVisDepende defaultMessage: 'Create interactive controls for easy dashboard manipulation.', }), stage: 'experimental', - feedbackMessage: defaultFeedbackMessage, visualization: InputControlVisController, visConfig: { defaults: { diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index 99daed98dbe640..c94021872b4e10 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -28,4 +28,3 @@ export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_w export { url } from './url'; export { now } from './now'; export { calculateObjectHash } from './calculate_object_hash'; -export { defaultFeedbackMessage } from './default_feedback_message'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 6f61e2c228970d..2911a9ae75689d 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -31,7 +31,6 @@ export { UiComponentInstance, url, createGetterSetter, - defaultFeedbackMessage, } from '../common'; export * from './core'; export * from '../common/errors'; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index 2b7466ffd6ab37..a1653c52892554 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -19,7 +19,7 @@ import { ButtonIconSide } from '@elastic/eui'; -export type TopNavMenuAction = (anchorElement: EventTarget) => void; +export type TopNavMenuAction = (anchorElement: HTMLElement) => void; export interface TopNavMenuData { id?: string; @@ -29,7 +29,7 @@ export interface TopNavMenuData { testId?: string; className?: string; disableButton?: boolean | (() => boolean); - tooltip?: string | (() => string); + tooltip?: string | (() => string | undefined); emphasize?: boolean; iconType?: string; iconSide?: ButtonIconSide; diff --git a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts index 53ef1f3f04ad9b..9e7346f3b673cd 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts @@ -51,14 +51,17 @@ export class SavedObjectLoader { } /** - * Retrieve a saved object by id. Returns a promise that completes when the object finishes + * Retrieve a saved object by id or create new one. + * Returns a promise that completes when the object finishes * initializing. - * @param id + * @param opts * @returns {Promise} */ - async get(id?: string) { + async get(opts?: Record | string) { + // can accept object as argument in accordance to SavedVis class + // see src/plugins/saved_objects/public/saved_object/saved_object_loader.ts // @ts-ignore - const obj = new this.Class(id); + const obj = new this.Class(opts); return obj.init(); } diff --git a/src/plugins/saved_objects/public/types.ts b/src/plugins/saved_objects/public/types.ts index 973a493c0a15e9..6db72b396a86ae 100644 --- a/src/plugins/saved_objects/public/types.ts +++ b/src/plugins/saved_objects/public/types.ts @@ -63,6 +63,7 @@ export interface SavedObjectSaveOpts { confirmOverwrite?: boolean; isTitleDuplicateConfirmed?: boolean; onTitleDuplicate?: () => void; + returnToOrigin?: boolean; } export interface SavedObjectCreationOpts { diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index 837dd9bff2c6de..c41315e7bc0dca 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -23,9 +23,13 @@ import { i18n } from '@kbn/i18n'; import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { Vis, PersistedState } from 'src/plugins/visualizations/public'; -import { SavedSearch } from 'src/plugins/discover/public'; +import { + Vis, + PersistedState, + VisualizeEmbeddableContract, +} from 'src/plugins/visualizations/public'; import { TimeRange } from 'src/plugins/data/public'; +import { SavedObject } from 'src/plugins/saved_objects/public'; import { DefaultEditorNavBar, OptionTab } from './navbar'; import { DefaultEditorControls } from './controls'; import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; @@ -34,6 +38,7 @@ import { SidebarTitle } from './sidebar_title'; import { Schema } from '../../schemas'; interface DefaultEditorSideBarProps { + embeddableHandler: VisualizeEmbeddableContract; isCollapsed: boolean; onClickCollapse: () => void; optionTabs: OptionTab[]; @@ -41,11 +46,12 @@ interface DefaultEditorSideBarProps { vis: Vis; isLinkedSearch: boolean; eventEmitter: EventEmitter; - savedSearch?: SavedSearch; + savedSearch?: SavedObject; timeRange: TimeRange; } function DefaultEditorSideBar({ + embeddableHandler, isCollapsed, onClickCollapse, optionTabs, @@ -104,12 +110,12 @@ function DefaultEditorSideBar({ aggs: state.data.aggs ? (state.data.aggs.aggs.map((agg) => agg.toJSON()) as any) : [], }, }); - eventEmitter.emit('updateVis'); + embeddableHandler.reload(); eventEmitter.emit('dirtyStateChange', { isDirty: false, }); setTouched(false); - }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter]); + }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter, embeddableHandler]); const onSubmit: KeyboardEventHandler = useCallback( (event) => { diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index ebc92170c87350..6713c2ce2391be 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -36,17 +36,17 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { Vis } from 'src/plugins/visualizations/public'; -import { SavedSearch } from 'src/plugins/discover/public'; +import { SavedObject } from 'src/plugins/saved_objects/public'; import { useKibana } from '../../../../kibana_react/public'; interface LinkedSearchProps { - savedSearch: SavedSearch; + savedSearch: SavedObject; eventEmitter: EventEmitter; } interface SidebarTitleProps { isLinkedSearch: boolean; - savedSearch?: SavedSearch; + savedSearch?: SavedObject; vis: Vis; eventEmitter: EventEmitter; } diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index 731358bdcbdec2..60b6ebab5ad8eb 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -59,7 +59,7 @@ function DefaultEditor({ embeddableHandler.render(visRef.current); setTimeout(() => { - eventEmitter.emit('apply'); + eventEmitter.emit('embeddableRendered'); }); return () => embeddableHandler.destroy(); @@ -102,6 +102,7 @@ function DefaultEditor({ initialWidth={editorInitialWidth} > { this.props.vis.params = this.state.model; - this.props.eventEmitter.emit('updateVis'); + this.props.embeddableHandler.reload(); this.props.eventEmitter.emit('dirtyStateChange', { isDirty: false, }); @@ -187,6 +187,7 @@ export class VisEditor extends Component { autoApply={this.state.autoApply} model={model} embeddableHandler={this.props.embeddableHandler} + eventEmitter={this.props.eventEmitter} vis={this.props.vis} timeRange={this.props.timeRange} uiState={this.uiState} diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js index 0ae1c86ae31175..23a9555da2452c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js @@ -73,6 +73,7 @@ class VisEditorVisualizationUI extends Component { this._handler = embeddableHandler; await this._handler.render(this._visEl.current); + this.props.eventEmitter.emit('embeddableRendered'); this._subscription = this._handler.handler.data$.subscribe((data) => { this.setPanelInterval(data.value.visData); @@ -279,6 +280,7 @@ VisEditorVisualizationUI.propTypes = { uiState: PropTypes.object, onToggleAutoApply: PropTypes.func, embeddableHandler: PropTypes.object, + eventEmitter: PropTypes.object, timeRange: PropTypes.object, dirty: PropTypes.bool, autoApply: PropTypes.bool, diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index c06f94efb3c493..649ee765cc6428 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -24,7 +24,6 @@ import { metricsRequestHandler } from './request_handler'; import { EditorController } from './application'; // @ts-ignore import { PANEL_TYPES } from '../common/panel_types'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; import { VisEditor } from './application/components/vis_editor_lazy'; export const metricsVisDefinition = { @@ -34,7 +33,6 @@ export const metricsVisDefinition = { defaultMessage: 'Build time-series using a visual pipeline interface', }), icon: 'visVisualBuilder', - feedbackMessage: defaultFeedbackMessage, visConfig: { defaults: { id: '61ca57f0-469d-11e7-af02-69e470af7417', diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index c864553c118b93..55ad134c053015 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -21,7 +21,6 @@ import { i18n } from '@kbn/i18n'; import { DefaultEditorSize } from '../../vis_default_editor/public'; import { VegaVisualizationDependencies } from './plugin'; import { VegaVisEditor } from './components'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; import { createVegaRequestHandler } from './vega_request_handler'; // @ts-ignore @@ -56,6 +55,5 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen showFilterBar: true, }, stage: 'experimental', - feedbackMessage: defaultFeedbackMessage, }; }; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 0bbf862216ed5b..2ac53c2c81acc9 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -51,4 +51,5 @@ export { VisSavedObject, VisResponseValue, } from './types'; +export { VisualizationListItem } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; diff --git a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts index eb00dce8aba2fd..8edf494ddc0ece 100644 --- a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts @@ -62,6 +62,7 @@ export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { title: vis.title, description: vis.description, visState: { + title: vis.title, type: vis.type, aggs: vis.data.aggs, params: vis.params, diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts index c6a25df7615a2b..d44fc2f4a75af0 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts @@ -56,7 +56,7 @@ export function createSavedVisLoader(services: SavedObjectKibanaServicesWithVisu source.icon = source.type.icon; source.image = source.type.image; source.typeTitle = source.type.title; - source.editUrl = `#/edit/${id}`; + source.editUrl = `/edit/${id}`; return source; }; diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index 3455d88b6ce9e1..daf275297fb822 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -35,6 +35,7 @@ export type VisualizationControllerConstructor = new ( ) => VisualizationController; export interface SavedVisState { + title: string; type: string; params: VisParams; aggs: AggConfigOptions[]; diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index aaab0566af65e6..e8ae48cdce1452 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -29,6 +29,7 @@ import { isFunction, defaults, cloneDeep } from 'lodash'; import { Assign } from '@kbn/utility-types'; +import { i18n } from '@kbn/i18n'; import { PersistedState } from './persisted_state'; import { getTypes, getAggs, getSearch, getSavedSearchLoader } from './services'; import { VisType } from './vis_types'; @@ -105,7 +106,13 @@ export class Vis { private getType(visType: string) { const type = getTypes().get(visType); if (!type) { - throw new Error(`Invalid type "${visType}"`); + const errorMessage = i18n.translate('visualizations.visualizationTypeInvalidMessage', { + defaultMessage: 'Invalid visualization type "{visType}"', + values: { + visType, + }, + }); + throw new Error(errorMessage); } return type; } @@ -150,7 +157,13 @@ export class Vis { const configStates = this.initializeDefaultsFromSchemas(aggs, this.type.schemas.all || []); if (!this.data.indexPattern) { if (aggs.length) { - throw new Error('trying to initialize aggs without index pattern'); + const errorMessage = i18n.translate( + 'visualizations.initializeWithoutIndexPatternErrorMessage', + { + defaultMessage: 'Trying to initialize aggs without index pattern', + } + ); + throw new Error(errorMessage); } return; } diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 2464bb72d26957..44b76a52b34fef 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -27,7 +27,6 @@ export interface BaseVisTypeOptions { icon?: string; image?: string; stage?: 'experimental' | 'beta' | 'production'; - feedbackMessage?: string; options?: Record; visualization: VisualizationControllerConstructor; visConfig?: Record; @@ -48,7 +47,7 @@ export class BaseVisType { icon?: string; image?: string; stage: 'experimental' | 'beta' | 'production'; - feedbackMessage: string; + isExperimental: boolean; options: Record; visualization: VisualizationControllerConstructor; visConfig: Record; @@ -87,7 +86,7 @@ export class BaseVisType { this.editorConfig = _.defaultsDeep({}, opts.editorConfig, { collections: {} }); this.options = _.defaultsDeep({}, opts.options, defaultOptions); this.stage = opts.stage || 'production'; - this.feedbackMessage = opts.feedbackMessage || ''; + this.isExperimental = opts.stage === 'experimental'; this.hidden = opts.hidden || false; this.requestHandler = opts.requestHandler || 'courier'; this.responseHandler = opts.responseHandler || 'none'; @@ -97,10 +96,6 @@ export class BaseVisType { this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; } - shouldMarkAsExperimentalInUI() { - return this.stage === 'experimental'; - } - public get schemas() { if (this.editorConfig && this.editorConfig.schemas) { return this.editorConfig.schemas; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 73e3360004e5a7..bc80d549c81e6f 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -27,6 +27,7 @@ export interface VisualizationListItem { title: string; description?: string; typeTitle: string; + image?: string; } export interface VisualizationsAppExtension { diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index cda45f3acc102d..c27cfec24b332d 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -9,7 +9,6 @@ "navigation", "savedObjects", "visualizations", - "dashboard", "embeddable" ], "optionalPlugins": ["home", "share"] diff --git a/src/plugins/visualize/public/application/_app.scss b/src/plugins/visualize/public/application/_app.scss deleted file mode 100644 index 8a52ebf4cc0883..00000000000000 --- a/src/plugins/visualize/public/application/_app.scss +++ /dev/null @@ -1,5 +0,0 @@ -.visAppWrapper { - display: flex; - flex-direction: column; - flex-grow: 1; -} diff --git a/src/plugins/visualize/public/application/index.scss b/src/plugins/visualize/public/application/app.scss similarity index 67% rename from src/plugins/visualize/public/application/index.scss rename to src/plugins/visualize/public/application/app.scss index 620380a77ba46c..f7f68fbc2c3597 100644 --- a/src/plugins/visualize/public/application/index.scss +++ b/src/plugins/visualize/public/application/app.scss @@ -5,6 +5,8 @@ // visChart__legend--small // visChart__legend-isLoading -@import 'app'; -@import 'editor/index'; -@import 'listing/index'; +.visAppWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; +} diff --git a/src/plugins/visualize/public/application/app.tsx b/src/plugins/visualize/public/application/app.tsx new file mode 100644 index 00000000000000..0e71d72a3d4c72 --- /dev/null +++ b/src/plugins/visualize/public/application/app.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './app.scss'; +import React, { useEffect } from 'react'; +import { Route, Switch, useLocation } from 'react-router-dom'; + +import { syncQueryStateWithUrl } from '../../../data/public'; +import { useKibana } from '../../../kibana_react/public'; +import { VisualizeServices } from './types'; +import { VisualizeEditor, VisualizeListing, VisualizeNoMatch } from './components'; +import { VisualizeConstants } from './visualize_constants'; + +export const VisualizeApp = () => { + const { + services: { + data: { query }, + kbnUrlStateStorage, + }, + } = useKibana(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, kbnUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, kbnUrlStateStorage, pathname]); + + return ( + + + + + + + + + + ); +}; diff --git a/src/plugins/visualize/public/application/application.ts b/src/plugins/visualize/public/application/application.ts deleted file mode 100644 index 60bb73d6de2ccb..00000000000000 --- a/src/plugins/visualize/public/application/application.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import './index.scss'; - -import angular, { IModule } from 'angular'; - -// required for i18nIdDirective -import 'angular-sanitize'; -// required for ngRoute -import 'angular-route'; - -import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; - -import { AppMountContext } from 'kibana/public'; -import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; -import { - configureAppAngularModule, - createTopNavDirective, - createTopNavHelper, -} from '../../../kibana_legacy/public'; - -// @ts-ignore -import { initVisualizeApp } from './legacy_app'; -import { VisualizeKibanaServices } from '../kibana_services'; - -let angularModuleInstance: IModule | null = null; - -export const renderApp = ( - element: HTMLElement, - appBasePath: string, - deps: VisualizeKibanaServices -) => { - if (!angularModuleInstance) { - angularModuleInstance = createLocalAngularModule(deps.core, deps.navigation); - // global routing stuff - configureAppAngularModule( - angularModuleInstance, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true, - deps.scopedHistory - ); - initVisualizeApp(angularModuleInstance, deps); - } - const $injector = mountVisualizeApp(appBasePath, element); - return () => $injector.get('$rootScope').$destroy(); -}; - -const mainTemplate = (basePath: string) => `

- -
-`; - -const moduleName = 'app/visualize'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -function mountVisualizeApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'visAppWrapper'); - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - // initialize global state handler - element.appendChild(mountpoint); - return $injector; -} - -function createLocalAngularModule(core: AppMountContext['core'], navigation: NavigationStart) { - createLocalI18nModule(); - createLocalTopNavModule(navigation); - - const visualizeAngularModule: IModule = angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'app/visualize/I18n', - 'app/visualize/TopNav', - ]); - return visualizeAngularModule; -} - -function createLocalTopNavModule(navigation: NavigationStart) { - angular - .module('app/visualize/TopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); -} - -function createLocalI18nModule() { - angular - .module('app/visualize/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/src/plugins/visualize/public/application/help_menu/help_menu_util.js b/src/plugins/visualize/public/application/components/experimental_vis_info.tsx similarity index 50% rename from src/plugins/visualize/public/application/help_menu/help_menu_util.js rename to src/plugins/visualize/public/application/components/experimental_vis_info.tsx index c297326f2e264e..51abb3ca530a4c 100644 --- a/src/plugins/visualize/public/application/help_menu/help_menu_util.js +++ b/src/plugins/visualize/public/application/components/experimental_vis_info.tsx @@ -17,18 +17,33 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; -export function addHelpMenuToAppChrome(chrome, docLinks) { - chrome.setHelpExtension({ - appName: i18n.translate('visualize.helpMenu.appName', { - defaultMessage: 'Visualize', - }), - links: [ - { - linkType: 'documentation', - href: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/visualize.html`, - }, - ], - }); -} +export const InfoComponent = () => { + const title = ( + <> + {' '} + + GitHub + + {'.'} + + ); + + return ( + + ); +}; + +export const ExperimentalVisInfo = memo(InfoComponent); diff --git a/src/plugins/visualize/public/application/editor/lib/index.ts b/src/plugins/visualize/public/application/components/index.ts similarity index 80% rename from src/plugins/visualize/public/application/editor/lib/index.ts rename to src/plugins/visualize/public/application/components/index.ts index 78589383925fb9..a3a7fde1d6569f 100644 --- a/src/plugins/visualize/public/application/editor/lib/index.ts +++ b/src/plugins/visualize/public/application/components/index.ts @@ -17,6 +17,6 @@ * under the License. */ -export { useVisualizeAppState } from './visualize_app_state'; -export { makeStateful } from './make_stateful'; -export { addEmbeddableToDashboardUrl } from '../../../../../dashboard/public/'; +export { VisualizeListing } from './visualize_listing'; +export { VisualizeEditor } from './visualize_editor'; +export { VisualizeNoMatch } from './visualize_no_match'; diff --git a/src/plugins/visualize/public/application/editor/_editor.scss b/src/plugins/visualize/public/application/components/visualize_editor.scss similarity index 100% rename from src/plugins/visualize/public/application/editor/_editor.scss rename to src/plugins/visualize/public/application/components/visualize_editor.scss diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx new file mode 100644 index 00000000000000..c571a5fb078bc5 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -0,0 +1,115 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './visualize_editor.scss'; +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { EventEmitter } from 'events'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiScreenReaderOnly } from '@elastic/eui'; + +import { useKibana } from '../../../../kibana_react/public'; +import { + useChromeVisibility, + useSavedVisInstance, + useVisualizeAppState, + useEditorUpdates, + useLinkedSearchUpdates, +} from '../utils'; +import { VisualizeServices } from '../types'; +import { ExperimentalVisInfo } from './experimental_vis_info'; +import { VisualizeTopNav } from './visualize_top_nav'; + +export const VisualizeEditor = () => { + const { id: visualizationIdFromUrl } = useParams(); + const [originatingApp, setOriginatingApp] = useState(); + const { services } = useKibana(); + const [eventEmitter] = useState(new EventEmitter()); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(!visualizationIdFromUrl); + + const isChromeVisible = useChromeVisibility(services.chrome); + const { savedVisInstance, visEditorRef, visEditorController } = useSavedVisInstance( + services, + eventEmitter, + isChromeVisible, + visualizationIdFromUrl + ); + const { appState, hasUnappliedChanges } = useVisualizeAppState( + services, + eventEmitter, + savedVisInstance + ); + const { isEmbeddableRendered, currentAppState } = useEditorUpdates( + services, + eventEmitter, + setHasUnsavedChanges, + appState, + savedVisInstance, + visEditorController + ); + useLinkedSearchUpdates(services, eventEmitter, appState, savedVisInstance); + + useEffect(() => { + const { originatingApp: value } = + services.embeddable.getStateTransfer(services.scopedHistory).getIncomingEditorState() || {}; + setOriginatingApp(value); + }, [services]); + + useEffect(() => { + // clean up all registered listeners if any is left + return () => { + eventEmitter.removeAllListeners(); + }; + }, [eventEmitter]); + + return ( +
+ {savedVisInstance && appState && currentAppState && ( + + )} + {savedVisInstance?.vis?.type?.isExperimental && } + {savedVisInstance && ( + +

+ +

+
+ )} +
+
+ ); +}; diff --git a/src/plugins/visualize/public/application/listing/_listing.scss b/src/plugins/visualize/public/application/components/visualize_listing.scss similarity index 100% rename from src/plugins/visualize/public/application/listing/_listing.scss rename to src/plugins/visualize/public/application/components/visualize_listing.scss diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx new file mode 100644 index 00000000000000..cbfbd6e0e3ab6b --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -0,0 +1,154 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './visualize_listing.scss'; + +import React, { useCallback, useRef, useMemo, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useUnmount, useMount } from 'react-use'; +import { useLocation } from 'react-router-dom'; + +import { useKibana, TableListView } from '../../../../kibana_react/public'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; +import { VisualizeServices } from '../types'; +import { VisualizeConstants } from '../visualize_constants'; +import { getTableColumns, getNoItemsMessage } from '../utils'; + +export const VisualizeListing = () => { + const { + services: { + application, + chrome, + history, + savedVisualizations, + toastNotifications, + visualizations, + savedObjects, + savedObjectsPublic, + uiSettings, + visualizeCapabilities, + }, + } = useKibana(); + const { pathname } = useLocation(); + const closeNewVisModal = useRef(() => {}); + const listingLimit = savedObjectsPublic.settings.getListingLimit(); + + useEffect(() => { + if (pathname === '/new') { + // In case the user navigated to the page via the /visualize/new URL we start the dialog immediately + closeNewVisModal.current = visualizations.showNewVisModal({ + onClose: () => { + // In case the user came via a URL to this page, change the URL to the regular landing page URL after closing the modal + history.push(VisualizeConstants.LANDING_PAGE_PATH); + }, + }); + } else { + // close modal window if exists + closeNewVisModal.current(); + } + }, [history, pathname, visualizations]); + + useMount(() => { + chrome.setBreadcrumbs([ + { + text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { + defaultMessage: 'Visualize', + }), + }, + ]); + chrome.docTitle.change( + i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize' }) + ); + }); + useUnmount(() => closeNewVisModal.current()); + + const createNewVis = useCallback(() => { + closeNewVisModal.current = visualizations.showNewVisModal(); + }, [visualizations]); + + const editItem = useCallback( + ({ editUrl, editApp }) => { + if (editApp) { + application.navigateToApp(editApp, { path: editUrl }); + return; + } + // for visualizations the edit and view URLs are the same + history.push(editUrl); + }, + [application, history] + ); + + const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); + const tableColumns = useMemo(() => getTableColumns(application, history), [application, history]); + + const fetchItems = useCallback( + (filter) => { + const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); + return savedVisualizations + .findListItems(filter, listingLimit) + .then(({ total, hits }: { total: number; hits: object[] }) => ({ + total, + hits: hits.filter((result: any) => isLabsEnabled || result.type.stage !== 'experimental'), + })); + }, + [listingLimit, savedVisualizations, uiSettings] + ); + + const deleteItems = useCallback( + async (selectedItems: object[]) => { + await Promise.all( + selectedItems.map((item: any) => savedObjects.client.delete(item.savedObjectType, item.id)) + ).catch((error) => { + toastNotifications.addError(error, { + title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), + }); + }); + }, + [savedObjects.client, toastNotifications] + ); + + return ( + + ); +}; diff --git a/src/plugins/visualize/public/application/components/visualize_no_match.tsx b/src/plugins/visualize/public/application/components/visualize_no_match.tsx new file mode 100644 index 00000000000000..7776c5e8ce4866 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_no_match.tsx @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { useKibana, toMountPoint } from '../../../../kibana_react/public'; +import { VisualizeServices } from '../types'; +import { VisualizeConstants } from '../visualize_constants'; + +let bannerId: string; + +export const VisualizeNoMatch = () => { + const { services } = useKibana(); + + useEffect(() => { + services.restorePreviousUrl(); + + const { navigated } = services.kibanaLegacy.navigateToLegacyKibanaUrl( + services.history.location.pathname + ); + + if (!navigated) { + const bannerMessage = i18n.translate('visualize.noMatchRoute.bannerTitleText', { + defaultMessage: 'Page not found', + }); + + bannerId = services.overlays.banners.replace( + bannerId, + toMountPoint( + +

+ + {services.history.location.pathname} + + ), + }} + /> +

+
+ ) + ); + + // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around + setTimeout(() => { + services.overlays.banners.remove(bannerId); + }, 15000); + + services.history.replace(VisualizeConstants.LANDING_PAGE_PATH); + } + }, [services]); + + return null; +}; diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx new file mode 100644 index 00000000000000..2e7dba46487ad0 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; +import { isEqual } from 'lodash'; + +import { OverlayRef } from 'kibana/public'; +import { Query } from 'src/plugins/data/public'; +import { useKibana } from '../../../../kibana_react/public'; +import { + VisualizeServices, + VisualizeAppState, + VisualizeAppStateContainer, + SavedVisInstance, +} from '../types'; +import { APP_NAME } from '../visualize_constants'; +import { getTopNavConfig } from '../utils'; + +interface VisualizeTopNavProps { + currentAppState: VisualizeAppState; + isChromeVisible?: boolean; + isEmbeddableRendered: boolean; + hasUnsavedChanges: boolean; + setHasUnsavedChanges: (value: boolean) => void; + hasUnappliedChanges: boolean; + originatingApp?: string; + savedVisInstance: SavedVisInstance; + stateContainer: VisualizeAppStateContainer; + visualizationIdFromUrl?: string; +} + +const TopNav = ({ + currentAppState, + isChromeVisible, + isEmbeddableRendered, + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, +}: VisualizeTopNavProps) => { + const { services } = useKibana(); + const { TopNavMenu } = services.navigation.ui; + const { embeddableHandler, vis } = savedVisInstance; + const [inspectorSession, setInspectorSession] = useState(); + const openInspector = useCallback(() => { + const session = embeddableHandler.openInspector(); + setInspectorSession(session); + }, [embeddableHandler]); + + const updateQuery = useCallback( + ({ query }: { query?: Query }) => { + if (!isEqual(currentAppState.query, query)) { + stateContainer.transitions.set('query', query || currentAppState.query); + } else { + savedVisInstance.embeddableHandler.reload(); + } + }, + [currentAppState.query, savedVisInstance.embeddableHandler, stateContainer.transitions] + ); + + const config = useMemo(() => { + if (isEmbeddableRendered) { + return getTopNavConfig( + { + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + openInspector, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, + }, + services + ); + } + }, [ + isEmbeddableRendered, + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + openInspector, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, + services, + ]); + const [indexPattern, setIndexPattern] = useState(vis.data.indexPattern); + const showDatePicker = () => { + // tsvb loads without an indexPattern initially (TODO investigate). + // hide timefilter only if timeFieldName is explicitly undefined. + const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; + return vis.type.options.showTimePicker && hasTimeField; + }; + const showFilterBar = vis.type.options.showFilterBar; + const showQueryInput = vis.type.requiresSearch && vis.type.options.showQueryBar; + + useEffect(() => { + return () => { + if (inspectorSession) { + // Close the inspector if this scope is destroyed (e.g. because the user navigates away). + inspectorSession.close(); + } + }; + }, [inspectorSession]); + + useEffect(() => { + if (!vis.data.indexPattern) { + services.data.indexPatterns.getDefault().then((index) => { + if (index) { + setIndexPattern(index); + } + }); + } + }, [services.data.indexPatterns, vis.data.indexPattern]); + + return isChromeVisible ? ( + /** + * Most visualizations have all search bar components enabled. + * Some visualizations have fewer options, but all visualizations have the search bar. + * That's is why the showSearchBar prop is set. + * All visualizations also have the timepicker\autorefresh component, + * it is enabled by default in the TopNavMenu component. + */ + + ) : showFilterBar ? ( + /** + * The top nav is hidden in embed mode, but the filter bar must still be present so + * we show the filter bar on its own here if the chrome is not visible. + */ + + ) : null; +}; + +export const VisualizeTopNav = memo(TopNav); diff --git a/src/plugins/visualize/public/application/editor/_index.scss b/src/plugins/visualize/public/application/editor/_index.scss deleted file mode 100644 index 9d3ca4b5399472..00000000000000 --- a/src/plugins/visualize/public/application/editor/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'editor'; diff --git a/src/plugins/visualize/public/application/editor/editor.html b/src/plugins/visualize/public/application/editor/editor.html deleted file mode 100644 index 3c3455fb34f18c..00000000000000 --- a/src/plugins/visualize/public/application/editor/editor.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - -
-
-

-
-
- - - -

-

- - -
diff --git a/src/plugins/visualize/public/application/editor/editor.js b/src/plugins/visualize/public/application/editor/editor.js deleted file mode 100644 index d7c7828c58f23f..00000000000000 --- a/src/plugins/visualize/public/application/editor/editor.js +++ /dev/null @@ -1,763 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import angular from 'angular'; -import _ from 'lodash'; -import { Subscription } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { EventEmitter } from 'events'; - -import React from 'react'; -import { makeStateful, useVisualizeAppState } from './lib'; -import { VisualizeConstants } from '../visualize_constants'; -import { getEditBreadcrumbs } from '../breadcrumbs'; - -import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; -import { unhashUrl } from '../../../../kibana_utils/public'; -import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public'; -import { - addFatalError, - subscribeWithScope, - migrateLegacyQuery, -} from '../../../../kibana_legacy/public'; -import { showSaveModal, SavedObjectSaveModalOrigin } from '../../../../saved_objects/public'; -import { - esFilters, - connectToQueryState, - syncQueryStateWithUrl, - UI_SETTINGS, -} from '../../../../data/public'; - -import { initVisEditorDirective } from './visualization_editor'; -import { initVisualizationDirective } from './visualization'; - -import { getServices } from '../../kibana_services'; -import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../visualizations/public'; - -export function initEditorDirective(app, deps) { - app.directive('visualizeApp', function () { - return { - restrict: 'E', - controllerAs: 'visualizeApp', - controller: VisualizeAppController, - }; - }); - - initVisEditorDirective(app, deps); - initVisualizationDirective(app, deps); -} - -function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlStateStorage, history) { - const { - localStorage, - visualizeCapabilities, - share, - data: { query: queryService, indexPatterns }, - toastNotifications, - chrome, - core: { docLinks, fatalErrors, uiSettings, application }, - I18nContext, - setActiveUrl, - visualizations, - embeddable, - scopedHistory, - } = getServices(); - - const { - filterManager, - timefilter: { timefilter }, - } = queryService; - - // starts syncing `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - queryService, - kbnUrlStateStorage - ); - - // Retrieve the resolved SavedVis instance. - const { vis, savedVis, savedSearch, embeddableHandler } = $route.current.locals.resolved; - $scope.eventEmitter = new EventEmitter(); - const _applyVis = () => { - $scope.$apply(); - }; - // This will trigger a digest cycle. This is needed when vis is updated from a global angular like in visualize_embeddable.js. - $scope.eventEmitter.on('apply', _applyVis); - // vis is instance of src/legacy/ui/public/vis/vis.js. - // SearchSource is a promise-based stream of search results that can inherit from other search sources. - const searchSource = vis.data.searchSource; - - $scope.vis = vis; - $scope.savedSearch = savedSearch; - - const $appStatus = { - dirty: !savedVis.id, - }; - - const defaultQuery = { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || - uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }; - - const { originatingApp } = - embeddable.getStateTransfer(scopedHistory()).getIncomingEditorState() || {}; - $scope.getOriginatingApp = () => originatingApp; - - const visStateToEditorState = () => { - const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); - return { - uiState: vis.uiState.toJSON(), - query: vis.data.searchSource.getOwnField('query') || defaultQuery, - filters: vis.data.searchSource.getOwnField('filter') || [], - vis: { ...savedVisState.visState, title: vis.title }, - linked: !!savedVis.savedSearchId, - }; - }; - - const stateDefaults = visStateToEditorState(); - - const { stateContainer, stopStateSync } = useVisualizeAppState({ - stateDefaults, - kbnUrlStateStorage, - }); - - $scope.eventEmitter.on('dirtyStateChange', ({ isDirty }) => { - if (!isDirty) { - stateContainer.transitions.updateVisState(visStateToEditorState().vis); - } - $timeout(() => { - $scope.dirty = isDirty; - }); - }); - - $scope.eventEmitter.on('updateVis', () => { - embeddableHandler.reload(); - }); - - $scope.embeddableHandler = embeddableHandler; - - $scope.topNavMenu = [ - ...($scope.getOriginatingApp() && savedVis.id - ? [ - { - id: 'saveAndReturn', - label: i18n.translate('visualize.topNavMenu.saveAndReturnVisualizationButtonLabel', { - defaultMessage: 'Save and return', - }), - emphasize: true, - iconType: 'check', - description: i18n.translate( - 'visualize.topNavMenu.saveAndReturnVisualizationButtonAriaLabel', - { - defaultMessage: 'Finish editing visualization and return to the last app', - } - ), - testId: 'visualizesaveAndReturnButton', - disableButton() { - return Boolean($scope.dirty); - }, - tooltip() { - if ($scope.dirty) { - return i18n.translate( - 'visualize.topNavMenu.saveAndReturnVisualizationDisabledButtonTooltip', - { - defaultMessage: 'Apply or Discard your changes before finishing', - } - ); - } - }, - run: async () => { - const saveOptions = { - confirmOverwrite: false, - returnToOrigin: true, - }; - return doSave(saveOptions); - }, - }, - ] - : []), - ...(visualizeCapabilities.save - ? [ - { - id: 'save', - label: - savedVis.id && $scope.getOriginatingApp() - ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { - defaultMessage: 'save as', - }) - : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { - defaultMessage: 'save', - }), - emphasize: !savedVis.id || !$scope.getOriginatingApp(), - description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { - defaultMessage: 'Save Visualization', - }), - testId: 'visualizeSaveButton', - disableButton() { - return Boolean($scope.dirty); - }, - tooltip() { - if ($scope.dirty) { - return i18n.translate( - 'visualize.topNavMenu.saveVisualizationDisabledButtonTooltip', - { - defaultMessage: 'Apply or Discard your changes before saving', - } - ); - } - }, - run: async () => { - const onSave = ({ - newTitle, - newCopyOnSave, - isTitleDuplicateConfirmed, - onTitleDuplicate, - newDescription, - returnToOrigin, - }) => { - const currentTitle = savedVis.title; - savedVis.title = newTitle; - savedVis.copyOnSave = newCopyOnSave; - savedVis.description = newDescription; - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - returnToOrigin, - }; - return doSave(saveOptions).then((response) => { - // If the save wasn't successful, put the original values back. - if (!response.id || response.error) { - savedVis.title = currentTitle; - } - return response; - }); - }; - - const saveModal = ( - {}} - originatingApp={$scope.getOriginatingApp()} - /> - ); - showSaveModal(saveModal, I18nContext); - }, - }, - ] - : []), - { - id: 'share', - label: i18n.translate('visualize.topNavMenu.shareVisualizationButtonLabel', { - defaultMessage: 'share', - }), - description: i18n.translate('visualize.topNavMenu.shareVisualizationButtonAriaLabel', { - defaultMessage: 'Share Visualization', - }), - testId: 'shareTopNavButton', - run: (anchorElement) => { - const hasUnappliedChanges = $scope.dirty; - const hasUnsavedChanges = $appStatus.dirty; - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: true, - allowShortUrl: visualizeCapabilities.createShortUrl, - shareableUrl: unhashUrl(window.location.href), - objectId: savedVis.id, - objectType: 'visualization', - sharingData: { - title: savedVis.title, - }, - isDirty: hasUnappliedChanges || hasUnsavedChanges, - }); - }, - // disable the Share button if no action specified - disableButton: !share, - }, - { - id: 'inspector', - label: i18n.translate('visualize.topNavMenu.openInspectorButtonLabel', { - defaultMessage: 'inspect', - }), - description: i18n.translate('visualize.topNavMenu.openInspectorButtonAriaLabel', { - defaultMessage: 'Open Inspector for visualization', - }), - testId: 'openInspectorButton', - disableButton() { - return !embeddableHandler.hasInspector || !embeddableHandler.hasInspector(); - }, - run() { - const inspectorSession = embeddableHandler.openInspector(); - - if (inspectorSession) { - // Close the inspector if this scope is destroyed (e.g. because the user navigates away). - const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); - // Remove that watch in case the user closes the inspector session herself. - inspectorSession.onClose.finally(removeWatch); - } - }, - tooltip() { - if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { - return i18n.translate('visualize.topNavMenu.openInspectorDisabledButtonTooltip', { - defaultMessage: `This visualization doesn't support any inspectors.`, - }); - } - }, - }, - ]; - - if (savedVis.id) { - chrome.docTitle.change(savedVis.title); - } - - // sync initial app filters from state to filterManager - filterManager.setAppFilters(_.cloneDeep(stateContainer.getState().filters)); - // setup syncing of app filters between appState and filterManager - const stopSyncingAppFilters = connectToQueryState( - queryService, - { - set: ({ filters }) => stateContainer.transitions.set('filters', filters), - get: () => ({ filters: stateContainer.getState().filters }), - state$: stateContainer.state$.pipe(map((state) => ({ filters: state.filters }))), - }, - { - filters: esFilters.FilterStateStore.APP_STATE, - } - ); - - const stopAllSyncing = () => { - stopStateSync(); - stopSyncingQueryServiceStateWithUrl(); - stopSyncingAppFilters(); - }; - - // The savedVis is pulled from elasticsearch, but the appState is pulled from the url, with the - // defaults applied. If the url was from a previous session which included modifications to the - // appState then they won't be equal. - if (!_.isEqual(stateContainer.getState().vis, stateDefaults.vis)) { - try { - const { aggs, ...visState } = stateContainer.getState().vis; - vis.setState({ ...visState, data: { aggs } }); - } catch (error) { - // stop syncing url updtes with the state to prevent extra syncing - stopAllSyncing(); - - toastNotifications.addWarning({ - title: i18n.translate('visualize.visualizationTypeInvalidNotificationMessage', { - defaultMessage: 'Invalid visualization type', - }), - text: toMountPoint({error.message}), - }); - - history.replace(`${VisualizeConstants.LANDING_PAGE_PATH}?notFound=visualization`); - - // prevent further controller execution - return; - } - } - - $scope.filters = filterManager.getFilters(); - - $scope.onFiltersUpdated = (filters) => { - // The filters will automatically be set when the filterManager emits an update event (see below) - filterManager.setFilters(filters); - }; - - $scope.showSaveQuery = visualizeCapabilities.saveQuery; - - $scope.$watch( - () => visualizeCapabilities.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability; - } - ); - - const updateSavedQueryFromUrl = (savedQueryId) => { - if (!savedQueryId) { - delete $scope.savedQuery; - - return; - } - - if ($scope.savedQuery && $scope.savedQuery.id === savedQueryId) { - return; - } - - queryService.savedQueries.getSavedQuery(savedQueryId).then((savedQuery) => { - $scope.$evalAsync(() => { - $scope.updateSavedQuery(savedQuery); - }); - }); - }; - - function init() { - if (vis.data.indexPattern) { - $scope.indexPattern = vis.data.indexPattern; - } else { - indexPatterns.getDefault().then((defaultIndexPattern) => { - $scope.indexPattern = defaultIndexPattern; - }); - } - - const initialState = stateContainer.getState(); - - const handleLinkedSearch = (linked) => { - if (linked && !savedVis.savedSearchId && savedSearch) { - savedVis.savedSearchId = savedSearch.id; - vis.data.savedSearchId = savedSearch.id; - searchSource.setParent(savedSearch.searchSource); - } else if (!linked && savedVis.savedSearchId) { - delete savedVis.savedSearchId; - delete vis.data.savedSearchId; - } - }; - - // Create a PersistedState instance for uiState. - const { persistedState, unsubscribePersisted, persistOnChange } = makeStateful( - 'uiState', - stateContainer - ); - vis.uiState = persistedState; - vis.uiState.on('reload', embeddableHandler.reload); - $scope.uiState = persistedState; - $scope.savedVis = savedVis; - $scope.query = initialState.query; - $scope.searchSource = searchSource; - $scope.refreshInterval = timefilter.getRefreshInterval(); - handleLinkedSearch(initialState.linked); - - $scope.showFilterBar = () => { - return vis.type.options.showFilterBar; - }; - - $scope.showQueryInput = () => { - return vis.type.requiresSearch && vis.type.options.showQueryBar; - }; - - $scope.showQueryBarTimePicker = () => { - // tsvb loads without an indexPattern initially (TODO investigate). - // hide timefilter only if timeFieldName is explicitly undefined. - const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; - return vis.type.options.showTimePicker && hasTimeField; - }; - - $scope.timeRange = timefilter.getTime(); - - const unsubscribeStateUpdates = stateContainer.subscribe((state) => { - const newQuery = migrateLegacyQuery(state.query); - if (!_.isEqual(state.query, newQuery)) { - stateContainer.transitions.set('query', newQuery); - } - persistOnChange(state); - updateSavedQueryFromUrl(state.savedQuery); - - // if the browser history was changed manually we need to reflect changes in the editor - if ( - !_.isEqual( - { - ...visualizations.convertFromSerializedVis(vis.serialize()).visState, - title: vis.title, - }, - state.vis - ) - ) { - const { aggs, ...visState } = state.vis; - vis.setState({ - ...visState, - data: { - aggs, - }, - }); - embeddableHandler.reload(); - $scope.eventEmitter.emit('updateEditor'); - } - - $appStatus.dirty = true; - $scope.fetch(); - }); - - const updateTimeRange = () => { - $scope.timeRange = timefilter.getTime(); - $scope.$broadcast('render'); - }; - - // update the query if savedQuery is stored - updateSavedQueryFromUrl(initialState.savedQuery); - - const subscriptions = new Subscription(); - - subscriptions.add( - subscribeWithScope( - $scope, - timefilter.getRefreshIntervalUpdate$(), - { - next: () => { - $scope.refreshInterval = timefilter.getRefreshInterval(); - }, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - subscriptions.add( - subscribeWithScope( - $scope, - timefilter.getTimeUpdate$(), - { - next: updateTimeRange, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - - subscriptions.add( - chrome.getIsVisible$().subscribe((isVisible) => { - $scope.$evalAsync(() => { - $scope.isVisible = isVisible; - }); - }) - ); - - // update the searchSource when query updates - $scope.fetch = function () { - const { query, linked, filters } = stateContainer.getState(); - $scope.query = query; - handleLinkedSearch(linked); - vis.data.searchSource.setField('query', query); - vis.data.searchSource.setField('filter', filters); - $scope.$broadcast('render'); - }; - - // update the searchSource when filters update - subscriptions.add( - subscribeWithScope( - $scope, - filterManager.getUpdates$(), - { - next: () => { - $scope.filters = filterManager.getFilters(); - }, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - subscriptions.add( - subscribeWithScope( - $scope, - filterManager.getFetches$(), - { - next: $scope.fetch, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - - $scope.$on('$destroy', () => { - if ($scope._handler) { - $scope._handler.destroy(); - } - savedVis.destroy(); - subscriptions.unsubscribe(); - $scope.eventEmitter.off('apply', _applyVis); - - unsubscribePersisted(); - vis.uiState.off('reload', embeddableHandler.reload); - unsubscribeStateUpdates(); - - stopAllSyncing(); - }); - - $timeout(() => { - $scope.$broadcast('render'); - }); - } - - $scope.updateQueryAndFetch = function ({ query, dateRange }) { - const isUpdate = - (query && !_.isEqual(query, stateContainer.getState().query)) || - (dateRange && !_.isEqual(dateRange, $scope.timeRange)); - - stateContainer.transitions.set('query', query); - timefilter.setTime(dateRange); - - // If nothing has changed, trigger the fetch manually, otherwise it will happen as a result of the changes - if (!isUpdate) { - embeddableHandler.reload(); - } - }; - - $scope.onRefreshChange = function ({ isPaused, refreshInterval }) { - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : $scope.refreshInterval.value, - }); - }; - - $scope.onClearSavedQuery = () => { - delete $scope.savedQuery; - stateContainer.transitions.removeSavedQuery(defaultQuery); - filterManager.setFilters(filterManager.getGlobalFilters()); - }; - - const updateStateFromSavedQuery = (savedQuery) => { - stateContainer.transitions.updateFromSavedQuery(savedQuery); - - const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = filterManager.getGlobalFilters(); - filterManager.setFilters([...globalFilters, ...savedQueryFilters]); - - if (savedQuery.attributes.timefilter) { - timefilter.setTime({ - from: savedQuery.attributes.timefilter.from, - to: savedQuery.attributes.timefilter.to, - }); - if (savedQuery.attributes.timefilter.refreshInterval) { - timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); - } - } - }; - - $scope.updateSavedQuery = (savedQuery) => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }; - - /** - * Called when the user clicks "Save" button. - */ - function doSave(saveOptions) { - // vis.title was not bound and it's needed to reflect title into visState - const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; - stateContainer.transitions.setVis({ - title: savedVis.title, - type: savedVis.type || stateContainer.getState().vis.type, - }); - savedVis.searchSourceFields = searchSource.getSerializedFields(); - savedVis.visState = stateContainer.getState().vis; - savedVis.uiStateJSON = angular.toJson($scope.uiState.toJSON()); - $appStatus.dirty = false; - - return savedVis.save(saveOptions).then( - function (id) { - $scope.$evalAsync(() => { - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate( - 'visualize.topNavMenu.saveVisualization.successNotificationText', - { - defaultMessage: `Saved '{visTitle}'`, - values: { - visTitle: savedVis.title, - }, - } - ), - 'data-test-subj': 'saveVisualizationSuccess', - }); - - if ($scope.getOriginatingApp() && saveOptions.returnToOrigin) { - const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(savedVis.id)}`; - - // Manually insert a new url so the back button will open the saved visualization. - history.replace(appPath); - setActiveUrl(appPath); - if (newlyCreated && embeddable) { - embeddable - .getStateTransfer() - .navigateToWithEmbeddablePackage($scope.getOriginatingApp(), { - state: { id: savedVis.id, type: VISUALIZE_EMBEDDABLE_TYPE }, - }); - } else { - application.navigateToApp($scope.getOriginatingApp()); - } - } else if (savedVis.id === $route.current.params.id) { - chrome.docTitle.change(savedVis.lastSavedTitle); - chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); - savedVis.vis.title = savedVis.title; - savedVis.vis.description = savedVis.description; - } else { - history.replace({ - ...history.location, - pathname: `${VisualizeConstants.EDIT_PATH}/${savedVis.id}`, - }); - } - } - }); - return { id }; - }, - (error) => { - // eslint-disable-next-line - console.error(error); - toastNotifications.addDanger({ - title: i18n.translate('visualize.topNavMenu.saveVisualization.failureNotificationText', { - defaultMessage: `Error on saving '{visTitle}'`, - values: { - visTitle: savedVis.title, - }, - }), - text: error.message, - 'data-test-subj': 'saveVisualizationError', - }); - return { error }; - } - ); - } - - const unlinkFromSavedSearch = () => { - const searchSourceParent = savedSearch.searchSource; - const searchSourceGrandparent = searchSourceParent.getParent(); - const currentIndex = searchSourceParent.getField('index'); - - searchSource.setField('index', currentIndex); - searchSource.setParent(searchSourceGrandparent); - - stateContainer.transitions.unlinkSavedSearch({ - query: searchSourceParent.getField('query'), - parentFilters: searchSourceParent.getOwnField('filter'), - }); - - toastNotifications.addSuccess( - i18n.translate('visualize.linkedToSearch.unlinkSuccessNotificationText', { - defaultMessage: `Unlinked from saved search '{searchTitle}'`, - values: { - searchTitle: savedSearch.title, - }, - }) - ); - }; - - $scope.getAdditionalMessage = () => { - return ( - '' + - i18n.translate('visualize.experimentalVisInfoText', { - defaultMessage: 'This visualization is marked as experimental.', - }) + - ' ' + - vis.type.feedbackMessage - ); - }; - - $scope.eventEmitter.on('unlinkFromSavedSearch', unlinkFromSavedSearch); - - addHelpMenuToAppChrome(chrome, docLinks); - - init(); -} diff --git a/src/plugins/visualize/public/application/editor/lib/make_stateful.ts b/src/plugins/visualize/public/application/editor/lib/make_stateful.ts deleted file mode 100644 index c7163f9b7705d4..00000000000000 --- a/src/plugins/visualize/public/application/editor/lib/make_stateful.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PersistedState } from '../../../../../visualizations/public'; -import { ReduxLikeStateContainer } from '../../../../../kibana_utils/public'; -import { VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; - -/** - * @returns Create a PersistedState instance, initialize state changes subscriber/unsubscriber - */ -export function makeStateful( - prop: keyof VisualizeAppState, - stateContainer: ReduxLikeStateContainer -) { - // set up the persistedState state - const persistedState = new PersistedState(); - - // update the appState when the stateful instance changes - const updateOnChange = function () { - stateContainer.transitions.set(prop, persistedState.getChanges()); - }; - - const handlerOnChange = (method: 'on' | 'off') => - persistedState[method]('change', updateOnChange); - - handlerOnChange('on'); - const unsubscribePersisted = () => handlerOnChange('off'); - - // update the stateful object when the app state changes - const persistOnChange = function (state: VisualizeAppState) { - if (state[prop]) { - persistedState.set(state[prop]); - } - }; - - const appState = stateContainer.getState(); - - // if the thing we're making stateful has an appState value, write to persisted state - if (appState[prop]) persistedState.setSilent(appState[prop]); - - return { persistedState, unsubscribePersisted, persistOnChange }; -} diff --git a/src/plugins/visualize/public/application/editor/visualization.js b/src/plugins/visualize/public/application/editor/visualization.js deleted file mode 100644 index 26f61f3f0a2c2b..00000000000000 --- a/src/plugins/visualize/public/application/editor/visualization.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function initVisualizationDirective(app) { - app.directive('visualizationEmbedded', function ($timeout) { - return { - restrict: 'E', - scope: { - embeddableHandler: '=', - uiState: '=?', - timeRange: '=', - filters: '=', - query: '=', - appState: '=', - }, - link: function ($scope, element) { - $scope.renderFunction = async () => { - if (!$scope.rendered) { - $scope.embeddableHandler.render(element[0]); - $scope.rendered = true; - } - - $scope.embeddableHandler.updateInput({ - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - }); - }; - - $scope.$on('render', (event) => { - event.preventDefault(); - $timeout(() => { - $scope.renderFunction(); - }); - }); - - $scope.$on('$destroy', () => { - if ($scope.embeddableHandler) { - $scope.embeddableHandler.destroy(); - } - }); - }, - }; - }); -} diff --git a/src/plugins/visualize/public/application/editor/visualization_editor.js b/src/plugins/visualize/public/application/editor/visualization_editor.js deleted file mode 100644 index 4963d9bc5ed72e..00000000000000 --- a/src/plugins/visualize/public/application/editor/visualization_editor.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { DefaultEditorController } from '../../../../vis_default_editor/public'; - -export function initVisEditorDirective(app, deps) { - app.directive('visualizationEditor', function ($timeout) { - return { - restrict: 'E', - scope: { - vis: '=', - uiState: '=?', - timeRange: '=', - filters: '=', - query: '=', - savedSearch: '=', - embeddableHandler: '=', - eventEmitter: '=', - }, - link: function ($scope, element) { - const Editor = $scope.vis.type.editor || DefaultEditorController; - const editor = new Editor( - element[0], - $scope.vis, - $scope.eventEmitter, - $scope.embeddableHandler - ); - - $scope.renderFunction = () => { - editor.render({ - core: deps.core, - data: deps.data, - uiState: $scope.uiState, - timeRange: $scope.timeRange, - filters: $scope.filters, - query: $scope.query, - linked: !!$scope.vis.data.savedSearchId, - savedSearch: $scope.savedSearch, - }); - }; - - $scope.$on('render', (event) => { - event.preventDefault(); - $timeout(() => { - $scope.renderFunction(); - }); - }); - - $scope.$on('$destroy', () => { - editor.destroy(); - }); - }, - }; - }); -} diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx new file mode 100644 index 00000000000000..4bec244e6efc94 --- /dev/null +++ b/src/plugins/visualize/public/application/index.tsx @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; + +import { AppMountParameters } from 'kibana/public'; +import { KibanaContextProvider } from '../../../kibana_react/public'; +import { VisualizeApp } from './app'; +import { VisualizeServices } from './types'; +import { addHelpMenuToAppChrome, addBadgeToAppChrome } from './utils'; + +export const renderApp = ({ element }: AppMountParameters, services: VisualizeServices) => { + // add help link to visualize docs into app chrome menu + addHelpMenuToAppChrome(services.chrome, services.docLinks); + // add readonly badge if saving restricted + if (!services.visualizeCapabilities.save) { + addBadgeToAppChrome(services.chrome); + } + + const app = ( + + + + + + + + ); + + ReactDOM.render(app, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/visualize/public/application/legacy_app.js b/src/plugins/visualize/public/application/legacy_app.js deleted file mode 100644 index 452118f8097da0..00000000000000 --- a/src/plugins/visualize/public/application/legacy_app.js +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { createHashHistory } from 'history'; - -import { createKbnUrlStateStorage, redirectWhenMissing } from '../../../kibana_utils/public'; -import { createSavedSearchesLoader } from '../../../discover/public'; - -import editorTemplate from './editor/editor.html'; -import visualizeListingTemplate from './listing/visualize_listing.html'; - -import { initVisualizeAppDirective } from './visualize_app'; -import { VisualizeConstants } from './visualize_constants'; -import { VisualizeListingController } from './listing/visualize_listing'; - -import { - getLandingBreadcrumbs, - getWizardStep1Breadcrumbs, - getCreateBreadcrumbs, - getEditBreadcrumbs, -} from './breadcrumbs'; - -const getResolvedResults = (deps) => { - const { core, data, visualizations, createVisEmbeddableFromObject } = deps; - - const results = {}; - - return (savedVis) => { - results.savedVis = savedVis; - const serializedVis = visualizations.convertToSerializedVis(savedVis); - return visualizations - .createVis(serializedVis.type, serializedVis) - .then((vis) => { - if (vis.type.setup) { - return vis.type.setup(vis).catch(() => vis); - } - return vis; - }) - .then((vis) => { - results.vis = vis; - return createVisEmbeddableFromObject(vis, { - timeRange: data.query.timefilter.timefilter.getTime(), - filters: data.query.filterManager.getFilters(), - }); - }) - .then((embeddableHandler) => { - results.embeddableHandler = embeddableHandler; - - embeddableHandler.getOutput$().subscribe((output) => { - if (output.error) { - core.notifications.toasts.addError(output.error, { - title: i18n.translate('visualize.error.title', { - defaultMessage: 'Visualization error', - }), - }); - } - }); - - if (results.vis.data.savedSearchId) { - return createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - indexPatterns: data.indexPatterns, - search: data.search, - chrome: core.chrome, - overlays: core.overlays, - }).get(results.vis.data.savedSearchId); - } - }) - .then((savedSearch) => { - if (savedSearch) { - results.savedSearch = savedSearch; - } - return results; - }); - }; -}; - -export function initVisualizeApp(app, deps) { - initVisualizeAppDirective(app, deps); - - app.factory('history', () => createHashHistory()); - app.factory('kbnUrlStateStorage', (history) => - createKbnUrlStateStorage({ - history, - useHash: deps.core.uiSettings.get('state:storeInSessionStorage'), - }) - ); - - app.config(function ($routeProvider) { - const defaults = { - reloadOnSearch: false, - requireUICapability: 'visualize.show', - badge: () => { - if (deps.visualizeCapabilities.save) { - return undefined; - } - - return { - text: i18n.translate('visualize.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('visualize.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save visualizations', - }), - iconType: 'glasses', - }; - }, - }; - - $routeProvider - .when(VisualizeConstants.LANDING_PAGE_PATH, { - ...defaults, - template: visualizeListingTemplate, - k7Breadcrumbs: getLandingBreadcrumbs, - controller: VisualizeListingController, - controllerAs: 'listingController', - resolve: { - createNewVis: () => false, - hasDefaultIndex: (history) => deps.data.indexPatterns.ensureDefaultIndexPattern(history), - }, - }) - .when(VisualizeConstants.WIZARD_STEP_1_PAGE_PATH, { - ...defaults, - template: visualizeListingTemplate, - k7Breadcrumbs: getWizardStep1Breadcrumbs, - controller: VisualizeListingController, - controllerAs: 'listingController', - resolve: { - createNewVis: () => true, - hasDefaultIndex: (history) => deps.data.indexPatterns.ensureDefaultIndexPattern(history), - }, - }) - .when(VisualizeConstants.CREATE_PATH, { - ...defaults, - template: editorTemplate, - k7Breadcrumbs: getCreateBreadcrumbs, - resolve: { - resolved: function ($route, history) { - const { data, savedVisualizations, visualizations, toastNotifications } = deps; - const visTypes = visualizations.all(); - const visType = find(visTypes, { name: $route.current.params.type }); - const shouldHaveIndex = visType.requiresSearch && visType.options.showIndexSelection; - const hasIndex = - $route.current.params.indexPattern || $route.current.params.savedSearchId; - if (shouldHaveIndex && !hasIndex) { - throw new Error( - i18n.translate( - 'visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage', - { - defaultMessage: 'You must provide either an indexPattern or a savedSearchId', - } - ) - ); - } - - // This delay is needed to prevent some navigation issues in Firefox/Safari. - // see https://github.com/elastic/kibana/issues/65161 - const delay = (res) => { - return new Promise((resolve) => { - setTimeout(() => resolve(res), 0); - }); - }; - - return data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => savedVisualizations.get($route.current.params)) - .then((savedVis) => { - savedVis.searchSourceFields = { index: $route.current.params.indexPattern }; - return savedVis; - }) - .then(getResolvedResults(deps)) - .then(delay) - .catch( - redirectWhenMissing({ - history, - mapping: VisualizeConstants.LANDING_PAGE_PATH, - toastNotifications, - }) - ); - }, - }, - }) - .when(`${VisualizeConstants.EDIT_PATH}/:id`, { - ...defaults, - template: editorTemplate, - k7Breadcrumbs: getEditBreadcrumbs, - resolve: { - resolved: function ($route, history) { - const { chrome, data, savedVisualizations, toastNotifications } = deps; - - return data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => savedVisualizations.get($route.current.params.id)) - .then((savedVis) => { - chrome.recentlyAccessed.add(savedVis.getFullPath(), savedVis.title, savedVis.id); - return savedVis; - }) - .then(getResolvedResults(deps)) - .catch( - redirectWhenMissing({ - history, - navigateToApp: deps.core.application.navigateToApp, - basePath: deps.core.http.basePath, - mapping: { - visualization: VisualizeConstants.LANDING_PAGE_PATH, - search: { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - 'index-pattern': { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - 'index-pattern-field': { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - }, - toastNotifications, - onBeforeRedirect() { - deps.setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); - }, - }) - ); - }, - }, - }) - .otherwise({ - resolveRedirectTo: function ($rootScope) { - const path = window.location.hash.substr(1); - deps.restorePreviousUrl(); - $rootScope.$applyAsync(() => { - const { navigated } = deps.kibanaLegacy.navigateToLegacyKibanaUrl(path); - if (!navigated) { - deps.kibanaLegacy.navigateToDefaultApp(); - } - }); - // prevent angular from completing the navigation - return new Promise(() => {}); - }, - }); - }); -} diff --git a/src/plugins/visualize/public/application/listing/_index.scss b/src/plugins/visualize/public/application/listing/_index.scss deleted file mode 100644 index 924c164e467d88..00000000000000 --- a/src/plugins/visualize/public/application/listing/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'listing'; diff --git a/src/plugins/visualize/public/application/listing/visualize_listing.html b/src/plugins/visualize/public/application/listing/visualize_listing.html deleted file mode 100644 index 8838348e0b6796..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing.html +++ /dev/null @@ -1,13 +0,0 @@ -
- -
diff --git a/src/plugins/visualize/public/application/listing/visualize_listing.js b/src/plugins/visualize/public/application/listing/visualize_listing.js deleted file mode 100644 index e8e8d92034113a..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing.js +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; -import { withI18nContext } from './visualize_listing_table'; - -import { VisualizeConstants } from '../visualize_constants'; -import { i18n } from '@kbn/i18n'; - -import { getServices } from '../../kibana_services'; -import { syncQueryStateWithUrl } from '../../../../data/public'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; - -import { EuiLink } from '@elastic/eui'; -import React from 'react'; - -export function initListingDirective(app, I18nContext) { - app.directive('visualizeListingTable', (reactDirective) => - reactDirective(withI18nContext(I18nContext)) - ); -} - -export function VisualizeListingController($scope, createNewVis, kbnUrlStateStorage, history) { - const { - addBasePath, - chrome, - savedObjectsClient, - savedVisualizations, - data: { query }, - toastNotifications, - visualizations, - core: { docLinks, savedObjects, uiSettings, application }, - savedObjects: savedObjectsPublic, - } = getServices(); - - chrome.docTitle.change( - i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize' }) - ); - - // syncs `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - query, - kbnUrlStateStorage - ); - - const { - timefilter: { timefilter }, - } = query; - - timefilter.disableAutoRefreshSelector(); - timefilter.disableTimeRangeSelector(); - - this.addBasePath = addBasePath; - this.uiSettings = uiSettings; - this.savedObjects = savedObjects; - - this.createNewVis = () => { - this.closeNewVisModal = visualizations.showNewVisModal(); - }; - - this.editItem = ({ editUrl, editApp }) => { - if (editApp) { - application.navigateToApp(editApp, { path: editUrl }); - return; - } - // for visualizations the edit and view URLs are the same - window.location.href = addBasePath(editUrl); - }; - - this.getViewElement = (field, record) => { - const dataTestSubj = `visListingTitleLink-${record.title.split(' ').join('-')}`; - if (record.editApp) { - return ( - { - application.navigateToApp(record.editApp, { path: record.editUrl }); - }} - data-test-subj={dataTestSubj} - > - {field} - - ); - } else if (record.editUrl) { - return ( - - {field} - - ); - } else { - return {field}; - } - }; - - if (createNewVis) { - // In case the user navigated to the page via the /visualize/new URL we start the dialog immediately - this.closeNewVisModal = visualizations.showNewVisModal({ - onClose: () => { - // In case the user came via a URL to this page, change the URL to the regular landing page URL after closing the modal - history.push({ - // Should preserve querystring part so the global state is preserved. - ...history.location, - pathname: VisualizeConstants.LANDING_PAGE_PATH, - }); - }, - }); - } - - this.fetchItems = (filter) => { - const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); - return savedVisualizations - .findListItems(filter, savedObjectsPublic.settings.getListingLimit()) - .then((result) => { - this.totalItems = result.total; - - return { - total: result.total, - hits: result.hits.filter( - (result) => isLabsEnabled || result.type.stage !== 'experimental' - ), - }; - }); - }; - - this.deleteSelectedItems = function deleteSelectedItems(selectedItems) { - return Promise.all( - selectedItems.map((item) => { - return savedObjectsClient.delete(item.savedObjectType, item.id); - }) - ).catch((error) => { - toastNotifications.addError(error, { - title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); - }); - }; - - chrome.setBreadcrumbs([ - { - text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { - defaultMessage: 'Visualize', - }), - }, - ]); - - this.listingLimit = savedObjectsPublic.settings.getListingLimit(); - this.initialPageSize = savedObjectsPublic.settings.getPerPage(); - - addHelpMenuToAppChrome(chrome, docLinks); - - $scope.$on('$destroy', () => { - if (this.closeNewVisModal) { - this.closeNewVisModal(); - } - - stopSyncingQueryServiceStateWithUrl(); - }); -} diff --git a/src/plugins/visualize/public/application/listing/visualize_listing_table.js b/src/plugins/visualize/public/application/listing/visualize_listing_table.js deleted file mode 100644 index fcd62d7ddee732..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing_table.js +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { TableListView } from '../../../../kibana_react/public'; - -import { EuiIcon, EuiBetaBadge, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; - -import { getServices } from '../../kibana_services'; - -class VisualizeListingTable extends Component { - constructor(props) { - super(props); - } - - render() { - const { visualizeCapabilities, core, toastNotifications } = getServices(); - return ( - item.canDelete} - initialFilter={''} - noItemsFragment={this.getNoItemsMessage()} - entityName={i18n.translate('visualize.listing.table.entityName', { - defaultMessage: 'visualization', - })} - entityNamePlural={i18n.translate('visualize.listing.table.entityNamePlural', { - defaultMessage: 'visualizations', - })} - tableListTitle={i18n.translate('visualize.listing.table.listTitle', { - defaultMessage: 'Visualizations', - })} - toastNotifications={toastNotifications} - uiSettings={core.uiSettings} - /> - ); - } - - getTableColumns() { - const tableColumns = [ - { - field: 'title', - name: i18n.translate('visualize.listing.table.titleColumnName', { - defaultMessage: 'Title', - }), - sortable: true, - render: (field, record) => this.props.getViewElement(field, record), - }, - { - field: 'typeTitle', - name: i18n.translate('visualize.listing.table.typeColumnName', { - defaultMessage: 'Type', - }), - sortable: true, - render: (field, record) => ( - - {this.renderItemTypeIcon(record)} - {record.typeTitle} - {this.getBadge(record)} - - ), - }, - { - field: 'description', - name: i18n.translate('visualize.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - sortable: true, - render: (field, record) => {record.description}, - }, - ]; - - return tableColumns; - } - - getNoItemsMessage() { - if (this.props.hideWriteControls) { - return ( -
- - - - } - /> -
- ); - } - - return ( -
- - - - } - body={ - -

- -

-
- } - actions={ - - - - } - /> -
- ); - } - - renderItemTypeIcon(item) { - let icon; - if (item.image) { - icon = ( - - ); - } else { - icon = ( -