From af077cde73b61a9564e3d79d88634ec9ff20ea30 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 10 Mar 2022 12:06:34 -0500 Subject: [PATCH 01/66] Cherry-pick security/timelines changes --- .../common/assets/field_maps/ecs_field_map.ts | 15 +++++ .../common/ecs/process/index.ts | 9 +++ .../common/types/timeline/index.ts | 1 + x-pack/plugins/security_solution/kibana.json | 1 + .../common/components/events_viewer/index.tsx | 14 +++-- .../alerts_table/default_config.tsx | 1 + .../components/alerts_table/index.tsx | 2 +- .../navigation/events_query_tab_body.tsx | 2 +- .../components/graph_overlay/index.tsx | 36 ++++++++++- .../components/graph_overlay/translations.ts | 7 +++ .../timeline/body/actions/index.tsx | 35 +++++++++++ .../components/timeline/body/index.tsx | 2 +- .../components/timeline/body/translations.ts | 7 +++ .../timeline/query_tab_content/index.tsx | 2 +- .../timeline/session_tab_content/index.tsx | 48 +++++++++++++++ .../timeline/tabs_content/index.tsx | 61 +++++++++++++------ .../timeline/tabs_content/translations.ts | 7 +++ .../timelines/store/timeline/actions.ts | 10 +++ .../timelines/store/timeline/defaults.ts | 1 + .../timelines/store/timeline/helpers.ts | 40 ++++++++++++ .../public/timelines/store/timeline/model.ts | 4 ++ .../timelines/store/timeline/reducer.ts | 12 ++++ .../plugins/security_solution/public/types.ts | 2 + .../plugins/security_solution/tsconfig.json | 4 +- x-pack/plugins/session_view/public/index.ts | 2 + x-pack/plugins/session_view/public/types.ts | 9 +++ .../timelines/common/ecs/process/index.ts | 9 +++ .../timeline/factory/helpers/constants.ts | 9 +++ 28 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts index dc81e200032f7..c3e3aa5c604db 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts @@ -2401,6 +2401,21 @@ export const ecsFieldMap = { array: false, required: false, }, + 'process.entry_leader.entity_id': { + type: 'keyword', + array: false, + required: false, + }, + 'process.session_leader.entity_id': { + type: 'keyword', + array: false, + required: false, + }, + 'process.group_leader.entity_id': { + type: 'keyword', + array: false, + required: false, + }, 'process.executable': { type: 'keyword', array: false, diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index 2a58c6d5b47d0..02122c776e95d 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -11,6 +11,9 @@ export interface ProcessEcs { Ext?: Ext; command_line?: string[]; entity_id?: string[]; + entry_leader?: ProcessSessionData; + session_leader?: ProcessSessionData; + group_leader?: ProcessSessionData; exit_code?: number[]; hash?: ProcessHashData; parent?: ProcessParentData; @@ -25,6 +28,12 @@ export interface ProcessEcs { working_directory?: string[]; } +export interface ProcessSessionData { + entity_id?: string[]; + pid?: string[]; + name?: string[]; +} + export interface ProcessHashData { md5?: string[]; sha1?: string[]; 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 a8a47d0ca9719..a9f0f8bc7c1f9 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -477,6 +477,7 @@ export enum TimelineTabs { notes = 'notes', pinned = 'pinned', eql = 'eql', + session = 'session', } /** diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 9f22f229b33c1..bbb322eeea0c1 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -21,6 +21,7 @@ "licensing", "maps", "ruleRegistry", + "sessionView", "taskManager", "timelines", "triggersActionsUi", diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 5f88bb3f9aabd..ff6813b2eff2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -98,6 +98,7 @@ const StatefulEventsViewerComponent: React.FC = ({ rowRenderers, start, scopeId, + sessionViewId, showCheckboxes, sort, timelineQuery, @@ -153,11 +154,11 @@ const StatefulEventsViewerComponent: React.FC = ({ const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS; - const graphOverlay = useMemo( - () => - graphEventId != null && graphEventId.length > 0 ? : null, - [graphEventId, id] - ); + const graphOverlay = useMemo(() => { + const shouldShowOverlay = + (graphEventId != null && graphEventId.length > 0) || sessionViewId !== null; + return shouldShowOverlay ? : null; + }, [graphEventId, id, sessionViewId]); const setQuery = useCallback( (inspect, loading, refetch) => { dispatch(inputsActions.setQuery({ id, inputId: 'global', inspect, loading, refetch })); @@ -271,6 +272,7 @@ const makeMapStateToProps = () => { kqlMode, sort, showCheckboxes, + sessionViewId, } = timeline; return { @@ -288,6 +290,7 @@ const makeMapStateToProps = () => { query: getGlobalQuerySelector(state), sort, showCheckboxes, + sessionViewId, // Used to determine whether the footer should show (since it is hidden if the graph is showing.) // `getTimeline` actually returns `TimelineModel | undefined` graphEventId, @@ -333,6 +336,7 @@ export const StatefulEventsViewer = connector( prevProps.start === nextProps.start && deepEqual(prevProps.pageFilters, nextProps.pageFilters) && prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.sessionViewId === nextProps.sessionViewId && prevProps.start === nextProps.start && prevProps.utilityBar === nextProps.utilityBar && prevProps.additionalFilters === nextProps.additionalFilters && diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index dc8c5bf4de65e..7dc3561628193 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -176,4 +176,5 @@ export const requiredFieldsForActions = [ 'file.hash.sha256', 'host.os.family', 'event.code', + 'process.entry_leader.entity_id', ]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index c52d7a55d8449..0a8d42f6a8bab 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -104,7 +104,7 @@ export const AlertsTableComponent: React.FC = ({ const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); const { addWarning } = useAppToasts(); - const ACTION_BUTTON_COUNT = 4; + const ACTION_BUTTON_COUNT = 5; const getGlobalQuery = useCallback( (customFilters: Filter[]) => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index a4889183cdf3d..d162a145e99fb 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -70,7 +70,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); - const ACTION_BUTTON_COUNT = 4; + const ACTION_BUTTON_COUNT = 5; const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); useEffect(() => { 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 index 64475147edc9d..af03bc7d0d7d2 100644 --- 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 @@ -28,12 +28,17 @@ import { useGlobalFullScreen, useTimelineFullScreen, } from '../../../common/containers/use_full_screen'; +import { useKibana } from '../../../common/lib/kibana'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { isFullScreen } from '../timeline/body/column_headers'; -import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; +import { + updateTimelineGraphEventId, + updateTimelineSessionViewEventId, + updateTimelineSessionViewSessionId, +} from '../../../timelines/store/timeline/actions'; import { inputsActions } from '../../../common/store/actions'; import { Resolver } from '../../../resolver/view'; import { @@ -70,6 +75,12 @@ const FullScreenButtonIcon = styled(EuiButtonIcon)` margin: 4px 0 4px 0; `; +const ScrollableFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`} + overflow: hidden; + width: 100%; +`; + interface OwnProps { timelineId: TimelineId; } @@ -131,6 +142,14 @@ const GraphOverlayComponent: React.FC = ({ timelineId }) => { const graphEventId = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).graphEventId ); + const { sessionView } = useKibana().services; + const sessionViewId = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId + ); + const sessionViewMain = useMemo(() => { + return sessionViewId !== null ? sessionView.getSessionView(sessionViewId) : null; + }, [sessionView, sessionViewId]); + const getStartSelector = useMemo(() => startSelector(), []); const getEndSelector = useMemo(() => endSelector(), []); const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []); @@ -180,6 +199,8 @@ const GraphOverlayComponent: React.FC = ({ timelineId }) => { } } dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); + dispatch(updateTimelineSessionViewEventId({ id: timelineId, eventId: null })); + dispatch(updateTimelineSessionViewSessionId({ id: timelineId, eventId: null })); }, [dispatch, timelineId, setTimelineFullScreen, setGlobalFullScreen]); useEffect(() => { @@ -219,7 +240,18 @@ const GraphOverlayComponent: React.FC = ({ timelineId }) => { [defaultDataView.patternList, isInTimeline, timelinePatterns] ); - if (fullScreen && !isInTimeline) { + if (!isInTimeline && sessionViewId !== null) { + return ( + + + + {i18n.CLOSE_SESSION} + + + {sessionViewMain} + + ); + } else if (fullScreen && !isInTimeline) { return ( 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 index 53e40c79f74ac..b2343b667dcda 100644 --- 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 @@ -13,3 +13,10 @@ export const CLOSE_ANALYZER = i18n.translate( defaultMessage: 'Close analyzer', } ); + +export const CLOSE_SESSION = i18n.translate( + 'xpack.securitySolution.timeline.graphOverlay.closeSessionButton', + { + defaultMessage: 'Close Session', + } +); 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 8be6200d1e84a..c783107cee362 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 @@ -24,6 +24,8 @@ import { useShallowEqualSelector } from '../../../../../common/hooks/use_selecto import { setActiveTabTimeline, updateTimelineGraphEventId, + updateTimelineSessionViewSessionId, + updateTimelineSessionViewEventId, } from '../../../../store/timeline/actions'; import { useGlobalFullScreen, @@ -128,6 +130,24 @@ const ActionsComponent: React.FC = ({ } }, [dispatch, ecsData._id, timelineId, setGlobalFullScreen, setTimelineFullScreen]); + const entryLeader = useMemo(() => { + const { process } = ecsData; + const entryLeaderIds = process?.entry_leader?.entity_id; + if (entryLeaderIds !== undefined) { + return entryLeaderIds[0]; + } else { + return null; + } + }, [ecsData]); + + const openSessionView = useCallback(() => { + if (entryLeader !== null) { + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.session })); + dispatch(updateTimelineSessionViewSessionId({ id: timelineId, eventId: entryLeader })); + dispatch(updateTimelineSessionViewEventId({ id: timelineId, eventId: entryLeader })); + } + }, [dispatch, timelineId, entryLeader]); + return ( {showCheckboxes && !tGridEnabled && ( @@ -220,6 +240,21 @@ const ActionsComponent: React.FC = ({ ) : null} + {entryLeader !== null ? ( +
+ + + + + +
+ ) : null}
); 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 c41544c1c4b4c..26fabddc329d5 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 @@ -108,7 +108,7 @@ export const BodyComponent = React.memo( const { queryFields, selectAll } = useDeepEqualSelector((state) => getManageTimeline(state, id) ); - const ACTION_BUTTON_COUNT = 5; + const ACTION_BUTTON_COUNT = 6; const onRowSelected: OnRowSelected = useCallback( ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { 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 d104dc3a85f72..40649b7d2313a 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 @@ -28,6 +28,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); +export const OPEN_SESSION_VIEW = i18n.translate( + 'xpack.securitySolution.timeline.body.openSessionViewLabel', + { + defaultMessage: 'Open Session View', + } +); + export const INVESTIGATE = i18n.translate( 'xpack.securitySolution.timeline.body.actions.investigateLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index d23d09280aaa9..1a6fcbf7c25ba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -202,7 +202,7 @@ export const QueryTabContentComponent: React.FC = ({ } = useSourcererDataView(SourcererScopeName.timeline); const { uiSettings } = useKibana().services; - const ACTION_BUTTON_COUNT = 5; + const ACTION_BUTTON_COUNT = 6; const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); const { filterManager: activeFilterManager } = useDeepEqualSelector((state) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx new file mode 100644 index 0000000000000..700462f00aa79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { timelineSelectors } from '../../../store/timeline'; +import { useKibana } from '../../../../common/lib/kibana'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; + +const FullWidthFlexGroup = styled(EuiFlexGroup)` + margin: 0; + width: 100%; + overflow: hidden; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`} + overflow: hidden; +`; + +interface Props { + timelineId: TimelineId; +} + +const SessionTabContent: React.FC = ({ timelineId }) => { + const { sessionView } = useKibana().services; + + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const sessionViewId = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewId + ); + const sessionViewMain = useMemo(() => { + return sessionViewId !== null ? sessionView.getSessionView(sessionViewId) : null; + }, [sessionView, sessionViewId]); + + return {sessionViewMain}; +}; + +// eslint-disable-next-line import/no-default-export +export default SessionTabContent; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index e38e380292260..8477e9ed136dd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -51,6 +51,7 @@ const EqlTabContent = lazy(() => import('../eql_tab_content')); const GraphTabContent = lazy(() => import('../graph_tab_content')); const NotesTabContent = lazy(() => import('../notes_tab_content')); const PinnedTabContent = lazy(() => import('../pinned_tab_content')); +const SessionTabContent = lazy(() => import('../session_tab_content')); interface BasicTimelineTab { renderCellValue: (props: CellValueElementProps) => React.ReactNode; @@ -106,6 +107,13 @@ const NotesTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => )); NotesTab.displayName = 'NotesTab'; +const SessionTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => ( + }> + + +)); +SessionTab.displayName = 'SessionTab'; + const PinnedTab: React.FC<{ renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; @@ -132,6 +140,8 @@ const ActiveTimelineTab = memo( return ; case TimelineTabs.notes: return ; + case TimelineTabs.session: + return ; default: return null; } @@ -140,7 +150,8 @@ const ActiveTimelineTab = memo( ); const isGraphOrNotesTabs = useMemo( - () => [TimelineTabs.graph, TimelineTabs.notes].includes(activeTimelineTab), + () => + [TimelineTabs.graph, TimelineTabs.notes, TimelineTabs.session].includes(activeTimelineTab), [activeTimelineTab] ); @@ -262,33 +273,36 @@ const TabsContentComponent: React.FC = ({ [appNotes, allTimelineNoteIds, timelineDescription] ); + const setActiveTab = useCallback( + (tab: TimelineTabs) => { + dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: tab })); + }, + [dispatch, timelineId] + ); + const setQueryAsActiveTab = useCallback(() => { - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) - ); - }, [dispatch, timelineId]); + setActiveTab(TimelineTabs.query); + }, [setActiveTab]); const setEqlAsActiveTab = useCallback(() => { - dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.eql })); - }, [dispatch, timelineId]); + setActiveTab(TimelineTabs.eql); + }, [setActiveTab]); const setGraphAsActiveTab = useCallback(() => { - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph }) - ); - }, [dispatch, timelineId]); + setActiveTab(TimelineTabs.graph); + }, [setActiveTab]); const setNotesAsActiveTab = useCallback(() => { - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) - ); - }, [dispatch, timelineId]); + setActiveTab(TimelineTabs.notes); + }, [setActiveTab]); const setPinnedAsActiveTab = useCallback(() => { - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.pinned }) - ); - }, [dispatch, timelineId]); + setActiveTab(TimelineTabs.pinned); + }, [setActiveTab]); + + const setSessionAsActiveTab = useCallback(() => { + setActiveTab(TimelineTabs.session); + }, [setActiveTab]); useEffect(() => { if (!graphEventId && activeTab === TimelineTabs.graph) { @@ -358,6 +372,15 @@ const TabsContentComponent: React.FC = ({ )} + + {i18n.SESSION_TAB} + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts index 6e58beaca8209..b116d2f551045 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/translations.ts @@ -38,3 +38,10 @@ export const PINNED_TAB = i18n.translate( defaultMessage: 'Pinned', } ); + +export const SESSION_TAB = i18n.translate( + 'pack.securitySolution.timeline.tabs.sessionTabTimelineTitle', + { + defaultMessage: 'Session View', + } +); 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 fc13d163c1883..3f1159c3db1e7 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 @@ -81,6 +81,16 @@ export const updateTimelineGraphEventId = actionCreator<{ id: string; graphEvent 'UPDATE_TIMELINE_GRAPH_EVENT_ID' ); +export const updateTimelineSessionViewEventId = actionCreator<{ + id: string; + eventId: string | null; +}>('UPDATE_TIMELINE_SESSION_VIEW_EVENT_ID'); + +export const updateTimelineSessionViewSessionId = actionCreator<{ + id: string; + eventId: string | null; +}>('UPDATE_TIMELINE_SESSION_VIEW_SESSION_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/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 1ebb815480c82..7362ee9e76759 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -65,6 +65,7 @@ export const timelineDefaults: SubsetTimelineModel & savedObjectId: null, selectAll: false, selectedEventIds: {}, + sessionViewId: null, show: false, showCheckboxes: false, sort: [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index a123cdeb8f928..1a2c11925bfa3 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 @@ -287,6 +287,46 @@ export const updateGraphEventId = ({ }; }; +export const updateSessionViewEventId = ({ + id, + eventId, + timelineById, +}: { + id: string; + eventId: string; + timelineById: TimelineById; +}): TimelineById => { + const timeline = timelineById[id]; + + return { + ...timelineById, + [id]: { + ...timeline, + sessionViewId: eventId, + }, + }; +}; + +export const updateSessionViewSessionId = ({ + id, + eventId, + timelineById, +}: { + id: string; + eventId: string; + timelineById: TimelineById; +}): TimelineById => { + const timeline = timelineById[id]; + + return { + ...timelineById, + [id]: { + ...timeline, + sessionViewSessionId: eventId, + }, + }; +}; + const queryMatchCustomizer = (dp1: QueryMatch, dp2: QueryMatch) => { if (dp1.field === dp2.field && dp1.value === dp2.value && dp1.operator === dp2.operator) { return true; 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 18b6566b13b6c..735ef7803d07c 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 @@ -63,6 +63,8 @@ export type TimelineModel = TGridModelForTimeline & { resolveTimelineConfig?: ResolveTimelineConfig; showSaveModal?: boolean; savedQueryId?: string | null; + sessionViewId: string | null; + sessionViewSessionId: string | null; /** When true, show the timeline flyover */ show: boolean; /** status: active | draft */ @@ -118,6 +120,8 @@ export type SubsetTimelineModel = Readonly< | 'dateRange' | 'selectAll' | 'selectedEventIds' + | 'sessionViewId' + | 'sessionViewSessionId' | 'show' | 'showCheckboxes' | 'sort' 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 6a3aa68908bb5..d2110d1ff075a 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 @@ -44,6 +44,8 @@ import { updateTimeline, updateTimelineGraphEventId, updateTitleAndDescription, + updateTimelineSessionViewEventId, + updateTimelineSessionViewSessionId, toggleModalSaveTimeline, updateEqlOptions, setTimelineUpdatedAt, @@ -77,6 +79,8 @@ import { updateGraphEventId, updateFilters, updateTimelineEventType, + updateSessionViewEventId, + updateSessionViewSessionId, } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; @@ -146,6 +150,14 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateGraphEventId({ id, graphEventId, timelineById: state.timelineById }), })) + .case(updateTimelineSessionViewEventId, (state, { id, eventId }) => ({ + ...state, + timelineById: updateSessionViewEventId({ id, eventId, timelineById: state.timelineById }), + })) + .case(updateTimelineSessionViewSessionId, (state, { id, eventId }) => ({ + ...state, + timelineById: updateSessionViewSessionId({ id, eventId, timelineById: state.timelineById }), + })) .case(pinEvent, (state, { id, eventId }) => ({ ...state, timelineById: pinTimelineEvent({ id, eventId, timelineById: state.timelineById }), diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index d43f8752c9122..0b93fce0992db 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -25,6 +25,7 @@ import type { import type { CasesUiStart } from '../../cases/public'; import type { SecurityPluginSetup } from '../../security/public'; import type { TimelinesUIStart } from '../../timelines/public'; +import type { SessionViewStart } from '../../session_view/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { MlPluginSetup, MlPluginStart } from '../../ml/public'; @@ -65,6 +66,7 @@ export interface StartPlugins { newsfeed?: NewsfeedPublicPluginStart; triggersActionsUi: TriggersActionsStart; timelines: TimelinesUIStart; + sessionView: SessionViewStart; uiActions: UiActionsStart; ml?: MlPluginStart; spaces?: SpacesPluginStart; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index d518eaf7f8243..2492508a53905 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -41,6 +41,6 @@ { "path": "../ml/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../security/tsconfig.json" }, - { "path": "../timelines/tsconfig.json" } - ] + { "path": "../timelines/tsconfig.json" }, + { "path": "../session_view/tsconfig.json"} ] } diff --git a/x-pack/plugins/session_view/public/index.ts b/x-pack/plugins/session_view/public/index.ts index 90043e9a691dc..a9f5fcaa858ac 100644 --- a/x-pack/plugins/session_view/public/index.ts +++ b/x-pack/plugins/session_view/public/index.ts @@ -7,6 +7,8 @@ import { SessionViewPlugin } from './plugin'; +export type { SessionViewStart } from './types'; + export function plugin() { return new SessionViewPlugin(); } diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index 2349b8423eb36..b40bfa01b651a 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -47,3 +47,12 @@ export interface DetailPanelProcessLeader { entryMetaSourceIp: string; executable: string; } + +export interface SessionViewStart { + getSessionViewTableProcessTree: ({ + onOpenSessionView, + }: { + onOpenSessionView: (eventId: string) => void; + }) => JSX.Element; + getSessionView: (sessionEntityId: string) => JSX.Element; +} diff --git a/x-pack/plugins/timelines/common/ecs/process/index.ts b/x-pack/plugins/timelines/common/ecs/process/index.ts index 820ecc5560e6c..b3abf363e2876 100644 --- a/x-pack/plugins/timelines/common/ecs/process/index.ts +++ b/x-pack/plugins/timelines/common/ecs/process/index.ts @@ -10,6 +10,9 @@ import { Ext } from '../file'; export interface ProcessEcs { Ext?: Ext; entity_id?: string[]; + entry_leader?: ProcessSessionData; + session_leader?: ProcessSessionData; + group_leader?: ProcessSessionData; exit_code?: number[]; hash?: ProcessHashData; parent?: ProcessParentData; @@ -23,6 +26,12 @@ export interface ProcessEcs { working_directory?: string[]; } +export interface ProcessSessionData { + entity_id?: string[]; + pid?: string[]; + name?: string[]; +} + export interface ProcessHashData { md5?: string[]; sha1?: string[]; diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts index e764e32243c18..613d35617394e 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts @@ -213,6 +213,15 @@ export const TIMELINE_EVENTS_FIELDS = [ 'process.executable', 'process.title', 'process.working_directory', + 'process.entry_leader.entity_id', + 'process.entry_leader.name', + 'process.entry_leader.pid', + 'process.session_leader.entity_id', + 'process.session_leader.name', + 'process.session_leader.pid', + 'process.group_leader.entity_id', + 'process.group_leader.name', + 'process.group_leader.pid', 'zeek.session_id', 'zeek.connection.local_resp', 'zeek.connection.local_orig', From 0abc22b1e2cc3313fac139c7cf90e09189fae4ac Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 10 Mar 2022 17:06:04 -0500 Subject: [PATCH 02/66] Changes to make session_view work with generated data --- .../public/components/process_tree/helpers.ts | 11 ++++++----- .../public/components/process_tree/hooks.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts index d3d7af1c62eda..5bf88bfcf78fb 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -46,7 +46,6 @@ export const buildProcessTree = ( events.forEach((event) => { const process = processMap[event.process.entity_id]; const parentProcess = processMap[event.process.parent?.entity_id]; - // if session leader, or process already has a parent, return if (process.id === sessionEntityId || process.parent) { return; @@ -74,12 +73,14 @@ export const buildProcessTree = ( // with this new page of events processed, lets try re-parent any orphans orphans?.forEach((process) => { - const parentProcess = processMap[process.getDetails().process.parent.entity_id]; + const parentProcessId = process.getDetails().process.parent?.entity_id; - if (parentProcess) { + if (parentProcessId) { + const parentProcess = processMap[parentProcessId]; process.parent = parentProcess; // handy for recursive operations (like auto expand) - - parentProcess.children.push(process); + if (parentProcess !== undefined) { + parentProcess.children.push(process); + } } else { newOrphans.push(process); } diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index a8c6ffe8e75d3..2706ac30de8a2 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -138,11 +138,11 @@ export class ProcessImpl implements Process { group_leader: groupLeader, } = event.process; - const parentIsASessionLeader = parent.pid === sessionLeader.pid; // possibly bash, zsh or some other shell - const processIsAGroupLeader = pid === groupLeader.pid; + const parentIsASessionLeader = parent && sessionLeader && parent.pid === sessionLeader.pid; + const processIsAGroupLeader = groupLeader && pid === groupLeader.pid; const sessionIsInteractive = !!tty; - return sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader; + return !!(sessionIsInteractive && parentIsASessionLeader && processIsAGroupLeader); } getMaxAlertLevel() { @@ -170,15 +170,16 @@ export class ProcessImpl implements Process { // to be used as a source for the most up to date details // on the processes lifecycle. getDetailsMemo = memoizeOne((events: ProcessEvent[]) => { + // TODO: add these to generator const actionsToFind = [EventAction.fork, EventAction.exec, EventAction.end]; const filtered = events.filter((processEvent) => { - return actionsToFind.includes(processEvent.event.action); + return true; }); // because events is already ordered by @timestamp we take the last event // which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event. // If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time) - return filtered[filtered.length - 1] || ({} as ProcessEvent); + return filtered[filtered.length - 1]; }); } From 821c0c245dc3cff89b52f1228ee22a59b14a706c Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 17 Mar 2022 09:59:08 -0400 Subject: [PATCH 03/66] Demo tweaks --- .../components/events_viewer/index.test.tsx | 28 +++++++++++++++++++ .../public/hosts/pages/hosts.tsx | 2 +- .../components/process_tree_alerts/styles.ts | 2 +- .../public/components/session_view/index.tsx | 6 ++-- .../public/components/session_view/styles.ts | 7 +++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index cdc9cc9b6f32d..6a43b1c1d5c12 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -110,4 +110,32 @@ describe('StatefulEventsViewer', () => { wrapper.unmount(); expect(mockCloseEditor).toHaveBeenCalled(); }); + + test('renders the graph overlay when a graph event id is set', async () => { + const propsWithGraphId = { + ...testProps, + graphEventId: 'test', + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('overlayContainer').exists()).toBe(true); + }); + + test('renders the graph overlay when a session event id is set', async () => { + const propsWithGraphId = { + ...testProps, + sessionViewId: 'test', + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('overlayContainer').exists()).toBe(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index f850ff4c63026..5cd94cd0d01a6 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -178,7 +178,7 @@ const HostsComponent = () => { return ( <> - {indicesExist ? ( + {true ? ( diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts index d601891591305..79b74b4cb7f40 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts @@ -25,7 +25,7 @@ export const useStyles = () => { borderWidth: border.width.thin, borderRadius: border.radius.medium, maxWidth: 800, - backgroundColor: 'white', + backgroundColor: colors.mediumShade, '&>div': { borderTop: border.thin, marginTop: size.m, diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index 7a82edc94ff1b..4a2f89a79db97 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -87,10 +87,10 @@ export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionVie return ( <> - +
- +
{(EuiResizablePanel, EuiResizableButton) => ( <> diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts index d7159ec5b1b39..4d46d245c3f9c 100644 --- a/x-pack/plugins/session_view/public/components/session_view/styles.ts +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -26,9 +26,16 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { height: `${height}px`, }; + const nonGrowGroup: CSSObject = { + display: 'flex', + flexGrow: 0, + alignItems: 'stretch', + }; + return { processTree, detailPanel, + nonGrowGroup, }; }, [height, euiTheme]); From 30b507545b70e762973f676b2c33032e351a94ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 23 Mar 2022 09:24:59 -0400 Subject: [PATCH 04/66] [APM] Service groups: Add EuiTour steps for assisting users in creating their first service group and provide guidance on the navigation changes (#128068) --- .../service_group_save/create_button.tsx | 47 +++++++++++ .../service_group_save/edit_button.tsx | 47 +++++++++++ .../service_group_save/save_button.tsx | 31 +++----- .../service_group_card.tsx | 37 ++++++++- .../service_groups_list.tsx | 1 + .../service_groups/service_groups_tour.tsx | 78 +++++++++++++++++++ .../use_service_groups_tour.tsx | 31 ++++++++ 7 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_groups_tour.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx new file mode 100644 index 0000000000000..e80fae8581271 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; + +interface Props { + onClick: () => void; +} + +export function CreateButton({ onClick }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('createGroup'); + return ( + + { + dismissTour(); + onClick(); + }} + > + {i18n.translate('xpack.apm.serviceGroups.createGroupLabel', { + defaultMessage: 'Create group', + })} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx new file mode 100644 index 0000000000000..8325ffd401957 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; + +interface Props { + onClick: () => void; +} + +export function EditButton({ onClick }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('editGroup'); + return ( + + { + dismissTour(); + onClick(); + }} + > + {i18n.translate('xpack.apm.serviceGroups.editGroupLabel', { + defaultMessage: 'Edit group', + })} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx index 61828e240c20a..c0da29625d7ca 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx @@ -4,11 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; +import { CreateButton } from './create_button'; +import { EditButton } from './edit_button'; import { SaveGroupModal } from './save_modal'; export function ServiceGroupSaveButton() { @@ -32,16 +32,18 @@ export function ServiceGroupSaveButton() { ); const savedServiceGroup = data?.serviceGroup; + function onClick() { + setIsModalVisible((state) => !state); + } + return ( <> - { - setIsModalVisible((state) => !state); - }} - > - {isGroupEditMode ? EDIT_GROUP_LABEL : CREATE_GROUP_LABEL} - + {isGroupEditMode ? ( + + ) : ( + + )} + {isModalVisible && ( ); } - -const CREATE_GROUP_LABEL = i18n.translate( - 'xpack.apm.serviceGroups.createGroupLabel', - { defaultMessage: 'Create group' } -); -const EDIT_GROUP_LABEL = i18n.translate( - 'xpack.apm.serviceGroups.editGroupLabel', - { defaultMessage: 'Edit group' } -); diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx index 0975bbb4ae307..a73b849ee014e 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx @@ -18,12 +18,15 @@ import { ServiceGroup, SERVICE_GROUP_COLOR_DEFAULT, } from '../../../../../common/service_groups'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; interface Props { serviceGroup: ServiceGroup; hideServiceCount?: boolean; onClick?: () => void; href?: string; + withTour?: boolean; } export function ServiceGroupsCard({ @@ -31,7 +34,10 @@ export function ServiceGroupsCard({ hideServiceCount = false, onClick, href, + withTour, }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('serviceGroupCard'); + const cardProps: EuiCardProps = { style: { width: 286, height: 186 }, icon: ( @@ -69,10 +75,39 @@ export function ServiceGroupsCard({ )}
), - onClick, + onClick: () => { + dismissTour(); + if (onClick) { + onClick(); + } + }, href, }; + if (withTour) { + return ( + + + + + + ); + } + return ( diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx index 06c138f7f01cd..224e9822a8b60 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx @@ -38,6 +38,7 @@ export function ServiceGroupsListItems({ items }: Props) { /> ))} void; + children: React.ReactElement; +} + +export function ServiceGroupsTour({ + tourEnabled, + dismissTour, + title, + content, + children, +}: Props) { + return ( + + {content} + + + {i18n.translate( + 'xpack.apm.serviceGroups.tour.content.link.docs', + { + defaultMessage: 'docs', + } + )} + + ), + }} + /> + + } + isStepOpen={tourEnabled} + onFinish={() => {}} + maxWidth={300} + minWidth={300} + step={1} + stepsTotal={1} + title={title} + anchorPosition="leftUp" + footerAction={ + + {i18n.translate('xpack.apm.serviceGroups.tour.dismiss', { + defaultMessage: 'Dismiss', + })} + + } + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx b/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx new file mode 100644 index 0000000000000..ba27b0e2640e8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocalStorage } from '../../../hooks/use_local_storage'; +import { TourType } from './service_groups_tour'; + +const INITIAL_STATE: Record = { + createGroup: true, + editGroup: true, + serviceGroupCard: true, +}; + +export function useServiceGroupsTour(type: TourType) { + const [tourEnabled, setTourEnabled] = useLocalStorage( + 'apm.serviceGroupsTour', + INITIAL_STATE + ); + + return { + tourEnabled: tourEnabled[type], + dismissTour: () => + setTourEnabled({ + ...tourEnabled, + [type]: false, + }), + }; +} From 6be9d1e79793f7d2a5995ef29689af382a872bd2 Mon Sep 17 00:00:00 2001 From: Dmitrii Shevchenko Date: Wed, 23 Mar 2022 14:36:13 +0100 Subject: [PATCH 05/66] [Security Solution][Detections] Cleanup usages of old bulk rule CRUD endpoints (#126068) * Cleanup usages of old bulk CRUD endpoints * Apply suggestions from code review Co-authored-by: Garrett Spong Co-authored-by: Garrett Spong --- .../detection_alerts/acknowledged.spec.ts | 2 +- .../detection_rules/export_rule.spec.ts | 12 +- .../security_solution/cypress/tasks/common.ts | 22 +- .../cypress/tasks/rule_details.ts | 5 +- .../cypress/tasks/rules_bulk_edit.ts | 4 +- .../common/hooks/use_app_toasts.mock.ts | 14 +- .../rule_actions_overflow/index.test.tsx | 58 ++-- .../rules/rule_actions_overflow/index.tsx | 61 ++-- .../rules/rule_switch/index.test.tsx | 40 +-- .../components/rules/rule_switch/index.tsx | 31 +- .../detection_engine/rules/api.test.ts | 83 ----- .../containers/detection_engine/rules/api.ts | 58 ---- .../detection_engine/rules/types.ts | 13 - .../detection_engine/rules/all/actions.ts | 307 ++++++++--------- .../bulk_actions/bulk_edit_confirmation.tsx | 2 +- .../all/bulk_actions/use_bulk_actions.tsx | 148 +++----- .../rules/all/helpers.test.ts | 45 +-- .../detection_engine/rules/all/helpers.ts | 26 +- .../rules/all/rules_table_actions.test.tsx | 51 +-- .../rules/all/rules_table_actions.tsx | 51 +-- .../rules/all/use_columns.tsx | 8 +- .../detection_engine/rules/translations.ts | 319 ++++++++++++------ .../routes/rules/perform_bulk_action_route.ts | 6 + .../translations/translations/fr-FR.json | 10 +- .../translations/translations/ja-JP.json | 17 +- .../translations/translations/zh-CN.json | 17 +- 26 files changed, 615 insertions(+), 795 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts index 12f5587ce0d6c..a65abae52ae7e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts @@ -72,7 +72,7 @@ describe('Marking alerts as acknowledged with read only role', () => { loginAndWaitForPage(ALERTS_URL, ROLES.t2_analyst); createCustomRuleEnabled(getNewRule()); refreshPage(); - waitForAlertsToPopulate(100); + waitForAlertsToPopulate(500); }); it('Mark one alert as acknowledged when more than one open alerts are selected', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts index 7b84845d46323..8a6527c502b42 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts @@ -7,7 +7,7 @@ import { expectedExportedRule, getNewRule } from '../../objects/rule'; -import { TOASTER } from '../../screens/alerts_detection_rules'; +import { TOASTER_BODY } from '../../screens/alerts_detection_rules'; import { exportFirstRule } from '../../tasks/alerts_detection_rules'; import { createCustomRule } from '../../tasks/api_calls/rules'; @@ -19,19 +19,17 @@ import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; describe('Export rules', () => { beforeEach(() => { cleanKibana(); - cy.intercept( - 'POST', - '/api/detection_engine/rules/_export?exclude_export_details=false&file_name=rules_export.ndjson' - ).as('export'); + // Rules get exported via _bulk_action endpoint + cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action'); loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); createCustomRule(getNewRule()).as('ruleResponse'); }); it('Exports a custom rule', function () { exportFirstRule(); - cy.wait('@export').then(({ response }) => { + cy.wait('@bulk_action').then(({ response }) => { cy.wrap(response?.body).should('eql', expectedExportedRule(this.ruleResponse)); - cy.get(TOASTER).should( + cy.get(TOASTER_BODY).should( 'have.text', 'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.' ); diff --git a/x-pack/plugins/security_solution/cypress/tasks/common.ts b/x-pack/plugins/security_solution/cypress/tasks/common.ts index 65480e52dea40..bafe429180fd1 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common.ts @@ -6,7 +6,6 @@ */ import { esArchiverResetKibana } from './es_archiver'; -import { RuleEcs } from '../../common/ecs/rule'; import { LOADING_INDICATOR } from '../screens/security_header'; const primaryButton = 0; @@ -68,19 +67,14 @@ export const reload = () => { export const cleanKibana = () => { const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; - cy.request('GET', '/api/detection_engine/rules/_find').then((response) => { - const rules: RuleEcs[] = response.body.data; - - if (response.body.data.length > 0) { - rules.forEach((rule) => { - const jsonRule = rule; - cy.request({ - method: 'DELETE', - url: `/api/detection_engine/rules?rule_id=${jsonRule.rule_id}`, - headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, - }); - }); - } + cy.request({ + method: 'POST', + url: '/api/detection_engine/rules/_bulk_action', + body: { + query: '', + action: 'delete', + }, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, }); cy.request('POST', `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, { diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index d42ebcf9da68e..35def6967485c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -32,10 +32,11 @@ import { import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const enablesRule = () => { - cy.intercept('PATCH', '/api/detection_engine/rules/_bulk_update').as('bulk_update'); + // Rules get enabled via _bulk_action endpoint + cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action'); cy.get(RULE_SWITCH).should('be.visible'); cy.get(RULE_SWITCH).click(); - cy.wait('@bulk_update').then(({ response }) => { + cy.wait('@bulk_action').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts b/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts index b665762fbd0c5..387fe63cad9cb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts @@ -74,7 +74,7 @@ export const confirmBulkEditForm = () => cy.get(RULES_BULK_EDIT_FORM_CONFIRM_BTN export const waitForBulkEditActionToFinish = ({ rulesCount }: { rulesCount: number }) => { cy.get(BULK_ACTIONS_PROGRESS_BTN).should('be.disabled'); - cy.contains(TOASTER_BODY, `You’ve successfully updated ${rulesCount} rule`); + cy.contains(TOASTER_BODY, `You've successfully updated ${rulesCount} rule`); }; export const waitForElasticRulesBulkEditModal = (rulesCount: number) => { @@ -99,6 +99,6 @@ export const waitForMixedRulesBulkEditModal = ( cy.get(MODAL_CONFIRMATION_BODY).should( 'have.text', - `The update action will only be applied to ${customRulesCount} Custom rules you’ve selected.` + `The update action will only be applied to ${customRulesCount} Custom rules you've selected.` ); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts index 25c0f5411f25c..c0bb52b20c534 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts @@ -5,10 +5,22 @@ * 2.0. */ -const createAppToastsMock = () => ({ +import { UseAppToasts } from './use_app_toasts'; + +const createAppToastsMock = (): jest.Mocked => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + api: { + get$: jest.fn(), + add: jest.fn(), + remove: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + addDanger: jest.fn(), + addError: jest.fn(), + addInfo: jest.fn(), + }, }); export const useAppToastsMock = { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 6a62b05c2e319..3037a3c82f946 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -9,13 +9,13 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, + goToRuleEditPage, + executeRulesBulkAction, } from '../../../pages/detection_engine/rules/all/actions'; import { RuleActionsOverflow } from './index'; import { mockRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/lib/kibana', () => { const actual = jest.requireActual('../../../../common/lib/kibana'); return { @@ -29,25 +29,9 @@ jest.mock('../../../../common/lib/kibana', () => { }), }; }); +jest.mock('../../../pages/detection_engine/rules/all/actions'); -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - push: jest.fn(), - }), -})); - -jest.mock('../../../pages/detection_engine/rules/all/actions', () => { - const actual = jest.requireActual('../../../../common/lib/kibana'); - return { - ...actual, - exportRulesAction: jest.fn(), - deleteRulesAction: jest.fn(), - duplicateRulesAction: jest.fn(), - editRuleAction: jest.fn(), - }; -}); - -const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; +const executeRulesBulkActionMock = executeRulesBulkAction as jest.Mock; const flushPromises = () => new Promise(setImmediate); describe('RuleActionsOverflow', () => { @@ -206,7 +190,9 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-duplicate-rule"] button').simulate('click'); wrapper.update(); - expect(duplicateRulesAction).toHaveBeenCalled(); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); }); test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { @@ -218,11 +204,8 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-duplicate-rule"] button').simulate('click'); wrapper.update(); - expect(duplicateRulesAction).toHaveBeenCalledWith( - [rule], - [rule.id], - expect.anything(), - expect.anything() + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate', search: { ids: ['id'] } }) ); }); }); @@ -230,7 +213,9 @@ describe('RuleActionsOverflow', () => { test('it calls editRuleAction after the rule is duplicated', async () => { const rule = mockRule('id'); const ruleDuplicate = mockRule('newRule'); - duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); + executeRulesBulkActionMock.mockImplementation(() => + Promise.resolve({ attributes: { results: { created: [ruleDuplicate] } } }) + ); const wrapper = mount( ); @@ -240,8 +225,10 @@ describe('RuleActionsOverflow', () => { wrapper.update(); await flushPromises(); - expect(duplicateRulesAction).toHaveBeenCalled(); - expect(editRuleAction).toHaveBeenCalledWith(ruleDuplicate.id, expect.anything()); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); + expect(goToRuleEditPage).toHaveBeenCalledWith(ruleDuplicate.id, expect.anything()); }); describe('rules details export rule', () => { @@ -340,7 +327,9 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-delete-rule"] button').simulate('click'); wrapper.update(); - expect(deleteRulesAction).toHaveBeenCalled(); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete' }) + ); }); test('it calls deleteRulesAction with the rule.id when rules-details-delete-rule is clicked', () => { @@ -352,11 +341,8 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-delete-rule"] button').simulate('click'); wrapper.update(); - expect(deleteRulesAction).toHaveBeenCalledWith( - [rule.id], - expect.anything(), - expect.anything(), - expect.anything() + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete', search: { ids: ['id'] } }) ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index c97ae9d7d7756..d45159c61ce49 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -12,32 +12,30 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; +import { noop } from 'lodash'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; - -import { noop } from 'lodash/fp'; -import { Rule } from '../../../containers/detection_engine/rules'; -import * as i18n from './translations'; -import * as i18nActions from '../../../pages/detection_engine/rules/translations'; -import { useStateToaster } from '../../../../common/components/toasters'; -import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, - exportRulesAction, -} from '../../../pages/detection_engine/rules/all/actions'; +import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; -import { getToolTipContent } from '../../../../common/utils/privileges'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; import { useKibana } from '../../../../common/lib/kibana'; -import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { getToolTipContent } from '../../../../common/utils/privileges'; +import { Rule } from '../../../containers/detection_engine/rules'; +import { + executeRulesBulkAction, + goToRuleEditPage, +} from '../../../pages/detection_engine/rules/all/actions'; +import * as i18nActions from '../../../pages/detection_engine/rules/translations'; +import * as i18n from './translations'; const MyEuiButtonIcon = styled(EuiButtonIcon)` &.euiButtonIcon { svg { transform: rotate(90deg); } - border: 1px solid  ${({ theme }) => theme.euiColorPrimary}; + border: 1px solid ${({ theme }) => theme.euiColorPrimary}; width: 40px; height: 40px; } @@ -59,7 +57,7 @@ const RuleActionsOverflowComponent = ({ }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const { navigateToApp } = useKibana().services.application; - const [, dispatchToaster] = useStateToaster(); + const toasts = useAppToasts(); const onRuleDeletedCallback = useCallback(() => { navigateToApp(APP_UI_ID, { @@ -79,14 +77,15 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-duplicate-rule" onClick={async () => { closePopover(); - const createdRules = await duplicateRulesAction( - [rule], - [rule.id], - dispatchToaster, - noop - ); + const result = await executeRulesBulkAction({ + action: BulkAction.duplicate, + onSuccess: noop, + search: { ids: [rule.id] }, + toasts, + }); + const createdRules = result?.attributes.results.created; if (createdRules?.length) { - editRuleAction(createdRules[0].id, navigateToApp); + goToRuleEditPage(createdRules[0].id, navigateToApp); } }} > @@ -104,7 +103,12 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-export-rule" onClick={async () => { closePopover(); - await exportRulesAction([rule.rule_id], dispatchToaster, noop); + await executeRulesBulkAction({ + action: BulkAction.export, + onSuccess: noop, + search: { ids: [rule.id] }, + toasts, + }); }} > {i18nActions.EXPORT_RULE} @@ -116,7 +120,12 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-delete-rule" onClick={async () => { closePopover(); - await deleteRulesAction([rule.id], dispatchToaster, noop, onRuleDeletedCallback); + await executeRulesBulkAction({ + action: BulkAction.delete, + onSuccess: onRuleDeletedCallback, + search: { ids: [rule.id] }, + toasts, + }); }} > {i18nActions.DELETE_RULE} @@ -126,10 +135,10 @@ const RuleActionsOverflowComponent = ({ [ canDuplicateRuleWithActions, closePopover, - dispatchToaster, navigateToApp, onRuleDeletedCallback, rule, + toasts, userHasPermissions, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx index 7c10fd63b463a..ec643962d41a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx @@ -9,22 +9,30 @@ import { mount } from 'enzyme'; import React from 'react'; import { waitFor } from '@testing-library/react'; -import { enableRules } from '../../../containers/detection_engine/rules'; +import { performBulkAction } from '../../../containers/detection_engine/rules'; import { RuleSwitchComponent } from './index'; import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { useStateToaster, displayErrorToast } from '../../../../common/components/toasters'; import { useRulesTableContextOptional } from '../../../pages/detection_engine/rules/all/rules_table/rules_table_context'; import { useRulesTableContextMock } from '../../../pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context'; import { TestProviders } from '../../../../common/mock'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -jest.mock('../../../../common/components/toasters'); +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../containers/detection_engine/rules'); jest.mock('../../../pages/detection_engine/rules/all/rules_table/rules_table_context'); +const useAppToastsValueMock = useAppToastsMock.create(); + describe('RuleSwitch', () => { beforeEach(() => { - (useStateToaster as jest.Mock).mockImplementation(() => [[], jest.fn()]); - (enableRules as jest.Mock).mockResolvedValue([getRulesSchemaMock()]); + (useAppToasts as jest.Mock).mockReturnValue(useAppToastsValueMock); + (performBulkAction as jest.Mock).mockResolvedValue({ + attributes: { + summary: { created: 0, updated: 1, deleted: 0 }, + results: { updated: [getRulesSchemaMock()] }, + }, + }); (useRulesTableContextOptional as jest.Mock).mockReturnValue(null); }); @@ -65,25 +73,7 @@ describe('RuleSwitch', () => { test('it dispatches error toaster if "enableRules" call rejects', async () => { const mockError = new Error('uh oh'); - (enableRules as jest.Mock).mockRejectedValue(mockError); - - const wrapper = mount(, { - wrappingComponent: TestProviders, - }); - wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(displayErrorToast).toHaveBeenCalledTimes(1); - }); - }); - - test('it dispatches error toaster if "enableRules" call resolves with some errors', async () => { - (enableRules as jest.Mock).mockResolvedValue([ - getRulesSchemaMock(), - { error: { status_code: 400, message: 'error' } }, - { error: { status_code: 400, message: 'error' } }, - ]); + (performBulkAction as jest.Mock).mockRejectedValue(mockError); const wrapper = mount(, { wrappingComponent: TestProviders, @@ -92,7 +82,7 @@ describe('RuleSwitch', () => { await waitFor(() => { wrapper.update(); - expect(displayErrorToast).toHaveBeenCalledTimes(1); + expect(useAppToastsValueMock.addError).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx index 893a0d4d8de8b..a5f80de7acbdc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx @@ -12,11 +12,13 @@ import { EuiSwitch, EuiSwitchEvent, } from '@elastic/eui'; +import { noop } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { useStateToaster } from '../../../../common/components/toasters'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useUpdateRulesCache } from '../../../containers/detection_engine/rules/use_find_rules_query'; -import { enableRulesAction } from '../../../pages/detection_engine/rules/all/actions'; +import { executeRulesBulkAction } from '../../../pages/detection_engine/rules/all/actions'; import { useRulesTableContextOptional } from '../../../pages/detection_engine/rules/all/rules_table/rules_table_context'; const StaticSwitch = styled(EuiSwitch)` @@ -47,26 +49,29 @@ export const RuleSwitchComponent = ({ onChange, }: RuleSwitchProps) => { const [myIsLoading, setMyIsLoading] = useState(false); - const [, dispatchToaster] = useStateToaster(); const rulesTableContext = useRulesTableContextOptional(); const updateRulesCache = useUpdateRulesCache(); + const toasts = useAppToasts(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); - const rules = await enableRulesAction( - [id], - event.target.checked, - dispatchToaster, - rulesTableContext?.actions.setLoadingRules - ); - if (rules?.[0]) { - updateRulesCache(rules); - onChange?.(rules[0].enabled); + const bulkActionResponse = await executeRulesBulkAction({ + setLoadingRules: rulesTableContext?.actions.setLoadingRules, + toasts, + onSuccess: rulesTableContext ? undefined : noop, + action: event.target.checked ? BulkAction.enable : BulkAction.disable, + search: { ids: [id] }, + visibleRuleIds: [], + }); + if (bulkActionResponse?.attributes.results.updated.length) { + // The rule was successfully updated + updateRulesCache(bulkActionResponse.attributes.results.updated); + onChange?.(bulkActionResponse.attributes.results.updated[0].enabled); } setMyIsLoading(false); }, - [dispatchToaster, id, onChange, rulesTableContext?.actions.setLoadingRules, updateRulesCache] + [id, onChange, rulesTableContext, toasts, updateRulesCache] ); const showLoader = useMemo((): boolean => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 004d1c3b7693c..ecfa98bfa3076 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -12,9 +12,6 @@ import { patchRule, fetchRules, fetchRuleById, - enableRules, - deleteRules, - duplicateRules, createPrepackagedRules, importRules, exportRules, @@ -395,86 +392,6 @@ describe('Detections Rules API', () => { }); }); - describe('enableRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when enabling rules', async () => { - await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: true }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { - body: '[{"id":"mySuperRuleId","enabled":true},{"id":"mySuperRuleId_II","enabled":true}]', - method: 'PATCH', - }); - }); - test('check parameter url, body when disabling rules', async () => { - await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: false }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { - body: '[{"id":"mySuperRuleId","enabled":false},{"id":"mySuperRuleId_II","enabled":false}]', - method: 'PATCH', - }); - }); - test('happy path', async () => { - const ruleResp = await enableRules({ - ids: ['mySuperRuleId', 'mySuperRuleId_II'], - enabled: true, - }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - - describe('deleteRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when deleting rules', async () => { - await deleteRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'] }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_delete', { - body: '[{"id":"mySuperRuleId"},{"id":"mySuperRuleId_II"}]', - method: 'POST', - }); - }); - - test('happy path', async () => { - const ruleResp = await deleteRules({ - ids: ['mySuperRuleId', 'mySuperRuleId_II'], - }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - - describe('duplicateRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when duplicating rules', async () => { - await duplicateRules({ rules: rulesMock.data }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { - body: '[{"actions":[],"author":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"risk_score_mapping":[],"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","severity_mapping":[],"tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"author":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"risk_score_mapping":[],"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","severity_mapping":[],"tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]', - method: 'POST', - }); - }); - - test('check duplicated rules are disabled by default', async () => { - await duplicateRules({ rules: rulesMock.data.map((rule) => ({ ...rule, enabled: true })) }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [path, options] = fetchMock.mock.calls[0]; - expect(path).toBe('/api/detection_engine/rules/_bulk_create'); - const rules = JSON.parse(options.body); - expect(rules).toMatchObject([{ enabled: false }, { enabled: false }]); - }); - - test('happy path', async () => { - const ruleResp = await duplicateRules({ rules: rulesMock.data }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - describe('createPrepackagedRules', () => { beforeEach(() => { fetchMock.mockClear(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 427cf28ef8f2f..5b29671d05bac 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -30,9 +30,6 @@ import { import { UpdateRulesProps, CreateRulesProps, - DeleteRulesProps, - DuplicateRulesProps, - EnableRulesProps, FetchRulesProps, FetchRulesResponse, Rule, @@ -42,7 +39,6 @@ import { ExportDocumentsProps, ImportDataResponse, PrePackagedRulesStatusResponse, - BulkRuleResponse, PatchRuleProps, BulkActionProps, BulkActionResponseMap, @@ -197,60 +193,6 @@ export const pureFetchRuleById = async ({ signal, }); -/** - * Enables/Disables provided Rule ID's - * - * @param ids array of Rule ID's (not rule_id) to enable/disable - * @param enabled to enable or disable - * - * @throws An error if response is not OK - */ -export const enableRules = async ({ ids, enabled }: EnableRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`, { - method: 'PATCH', - body: JSON.stringify(ids.map((id) => ({ id, enabled }))), - }); - -/** - * Deletes provided Rule ID's - * - * @param ids array of Rule ID's (not rule_id) to delete - * - * @throws An error if response is not OK - */ -export const deleteRules = async ({ ids }: DeleteRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, { - method: 'POST', - body: JSON.stringify(ids.map((id) => ({ id }))), - }); - -/** - * Duplicates provided Rules - * - * @param rules to duplicate - * - * @throws An error if response is not OK - */ -export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`, { - method: 'POST', - body: JSON.stringify( - rules.map((rule) => ({ - ...rule, - name: `${rule.name} [${i18n.DUPLICATE}]`, - created_at: undefined, - created_by: undefined, - id: undefined, - rule_id: undefined, - updated_at: undefined, - updated_by: undefined, - enabled: false, - immutable: undefined, - execution_summary: undefined, - })) - ), - }); - /** * Perform bulk action with rules selected by a filter query * diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 85df24ec0258e..797f67e1fbae5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -219,19 +219,6 @@ export interface FetchRuleProps { signal: AbortSignal; } -export interface EnableRulesProps { - ids: string[]; - enabled: boolean; -} - -export interface DeleteRulesProps { - ids: string[]; -} - -export interface DuplicateRulesProps { - rules: Rule[]; -} - export interface BulkActionProps { action: Action; query?: string; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts index 8e98d24b17246..10c099e4bfcc8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts @@ -5,40 +5,28 @@ * 2.0. */ -import { Dispatch } from 'react'; import type { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; - -import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { APP_UI_ID } from '../../../../../../common/constants'; import { BulkAction, BulkActionEditPayload, } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; +import { HTTPError } from '../../../../../../common/detection_engine/types'; import { SecurityPageName } from '../../../../../app/types'; import { getEditRuleUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; -import { - ActionToaster, - displayErrorToast, - displaySuccessToast, - errorToToaster, -} from '../../../../../common/components/toasters'; +import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../../common/lib/telemetry'; import { downloadBlob } from '../../../../../common/utils/download_blob'; import { - deleteRules, - duplicateRules, - enableRules, - exportRules, + BulkActionResponse, + BulkActionSummary, performBulkAction, - Rule, } from '../../../../containers/detection_engine/rules'; -import { RulesTableActions } from './rules_table/rules_table_context'; -import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; import * as i18n from '../translations'; -import { bucketRulesResponse, getExportedRulesCounts } from './helpers'; +import { getExportedRulesCounts } from './helpers'; +import { RulesTableActions } from './rules_table/rules_table_context'; -export const editRuleAction = ( +export const goToRuleEditPage = ( ruleId: string, navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise ) => { @@ -48,177 +36,48 @@ export const editRuleAction = ( }); }; -export const duplicateRulesAction = async ( - rules: Rule[], - ruleIds: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'] -): Promise => { - try { - setLoadingRules({ ids: ruleIds, action: 'duplicate' }); - const response = await duplicateRules({ - // We cast this back and forth here as the front end types are not really the right io-ts ones - // and the two types conflict with each other. - rules: rules.map((rule) => transformOutput(rule as CreateRulesSchema) as Rule), - }); - const { errors, rules: createdRules } = bucketRulesResponse(response); - if (errors.length > 0) { - displayErrorToast( - i18n.DUPLICATE_RULE_ERROR, - errors.map((e) => e.error.message), - dispatchToaster - ); - } else { - displaySuccessToast(i18n.SUCCESSFULLY_DUPLICATED_RULES(ruleIds.length), dispatchToaster); - } - return createdRules; - } catch (error) { - errorToToaster({ title: i18n.DUPLICATE_RULE_ERROR, error, dispatchToaster }); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const exportRulesAction = async ( - exportRuleId: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'] -) => { - try { - setLoadingRules({ ids: exportRuleId, action: 'export' }); - const blob = await exportRules({ ids: exportRuleId }); - downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); - - const { exported } = await getExportedRulesCounts(blob); - displaySuccessToast( - i18n.SUCCESSFULLY_EXPORTED_RULES(exported, exportRuleId.length), - dispatchToaster - ); - } catch (e) { - displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const deleteRulesAction = async ( - ruleIds: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'], - onRuleDeleted?: () => void -) => { - try { - setLoadingRules({ ids: ruleIds, action: 'delete' }); - const response = await deleteRules({ ids: ruleIds }); - const { errors } = bucketRulesResponse(response); - if (errors.length > 0) { - displayErrorToast( - i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), - errors.map((e) => e.error.message), - dispatchToaster - ); - } else if (onRuleDeleted) { - onRuleDeleted(); - } - } catch (error) { - errorToToaster({ - title: i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), - error, - dispatchToaster, - }); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const enableRulesAction = async ( - ids: string[], - enabled: boolean, - dispatchToaster: Dispatch, - setLoadingRules?: RulesTableActions['setLoadingRules'] -) => { - const errorTitle = enabled - ? i18n.BATCH_ACTION_ENABLE_SELECTED_ERROR(ids.length) - : i18n.BATCH_ACTION_DISABLE_SELECTED_ERROR(ids.length); - - try { - setLoadingRules?.({ ids, action: enabled ? 'enable' : 'disable' }); - - const response = await enableRules({ ids, enabled }); - const { rules, errors } = bucketRulesResponse(response); - - if (errors.length > 0) { - displayErrorToast( - errorTitle, - errors.map((e) => e.error.message), - dispatchToaster - ); - } - - if (rules.some((rule) => rule.immutable)) { - track( - METRIC_TYPE.COUNT, - enabled ? TELEMETRY_EVENT.SIEM_RULE_ENABLED : TELEMETRY_EVENT.SIEM_RULE_DISABLED - ); - } - if (rules.some((rule) => !rule.immutable)) { - track( - METRIC_TYPE.COUNT, - enabled ? TELEMETRY_EVENT.CUSTOM_RULE_ENABLED : TELEMETRY_EVENT.CUSTOM_RULE_DISABLED - ); - } - - return rules; - } catch (e) { - displayErrorToast(errorTitle, [e.message], dispatchToaster); - } finally { - setLoadingRules?.({ ids: [], action: null }); - } -}; - interface ExecuteRulesBulkActionArgs { - visibleRuleIds: string[]; + visibleRuleIds?: string[]; action: BulkAction; toasts: UseAppToasts; search: { query: string } | { ids: string[] }; payload?: { edit?: BulkActionEditPayload[] }; - onSuccess?: (arg: { rulesCount: number }) => void; - onError?: (error: Error) => void; - setLoadingRules: RulesTableActions['setLoadingRules']; + onSuccess?: (toasts: UseAppToasts, action: BulkAction, summary: BulkActionSummary) => void; + onError?: (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void; + onFinish?: () => void; + setLoadingRules?: RulesTableActions['setLoadingRules']; } -const executeRulesBulkAction = async ({ - visibleRuleIds, +export const executeRulesBulkAction = async ({ + visibleRuleIds = [], action, setLoadingRules, toasts, search, payload, - onSuccess, - onError, + onSuccess = defaultSuccessHandler, + onError = defaultErrorHandler, + onFinish, }: ExecuteRulesBulkActionArgs) => { try { - setLoadingRules({ ids: visibleRuleIds, action }); + setLoadingRules?.({ ids: visibleRuleIds, action }); if (action === BulkAction.export) { - const blob = await performBulkAction({ ...search, action }); - downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); - const { exported, total } = await getExportedRulesCounts(blob); - - toasts.addSuccess(i18n.SUCCESSFULLY_EXPORTED_RULES(exported, total)); + const response = await performBulkAction({ ...search, action }); + downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`); + onSuccess(toasts, action, await getExportedRulesCounts(response)); } else { const response = await performBulkAction({ ...search, action, edit: payload?.edit }); + sendTelemetry(action, response); + onSuccess(toasts, action, response.attributes.summary); - onSuccess?.({ rulesCount: response.attributes.summary.succeeded }); - } - } catch (e) { - if (onError) { - onError(e); - } else { - toasts.addError(e, { title: i18n.BULK_ACTION_FAILED }); + return response; } + } catch (error) { + onError(toasts, action, error); } finally { - setLoadingRules({ ids: [], action: null }); + setLoadingRules?.({ ids: [], action: null }); + onFinish?.(); } }; @@ -240,3 +99,113 @@ export const initRulesBulkAction = (params: Omit rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkAction.enable + ? TELEMETRY_EVENT.SIEM_RULE_ENABLED + : TELEMETRY_EVENT.SIEM_RULE_DISABLED + ); + } + if (response.attributes.results.updated.some((rule) => !rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkAction.disable + ? TELEMETRY_EVENT.CUSTOM_RULE_ENABLED + : TELEMETRY_EVENT.CUSTOM_RULE_DISABLED + ); + } + } +} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx index 445bd33860be2..f4c8e7f9bf2a0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx @@ -64,7 +64,7 @@ const BulkEditConfirmationComponent = ({ > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx index fd12f9a71bf29..491b693a442ba 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx @@ -26,29 +26,18 @@ import { BulkActionEditPayload, } from '../../../../../../../common/detection_engine/schemas/common/schemas'; import { isMlRule } from '../../../../../../../common/machine_learning/helpers'; -import { displayWarningToast, useStateToaster } from '../../../../../../common/components/toasters'; import { canEditRuleWithActions } from '../../../../../../common/utils/privileges'; import { useRulesTableContext } from '../rules_table/rules_table_context'; import * as detectionI18n from '../../../translations'; import * as i18n from '../../translations'; -import { - deleteRulesAction, - duplicateRulesAction, - enableRulesAction, - exportRulesAction, - initRulesBulkAction, -} from '../actions'; +import { executeRulesBulkAction, initRulesBulkAction } from '../actions'; import { useHasActionsPrivileges } from '../use_has_actions_privileges'; import { useHasMlPermissions } from '../use_has_ml_permissions'; import { getCustomRulesCountFromCache } from './use_custom_rules_count'; import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts'; import { convertRulesFilterToKQL } from '../../../../../containers/detection_engine/rules/utils'; -import type { - BulkActionResponse, - FilterOptions, -} from '../../../../../containers/detection_engine/rules/types'; -import type { HTTPError } from '../../../../../../../common/detection_engine/types'; +import type { FilterOptions } from '../../../../../containers/detection_engine/rules/types'; import { useInvalidateRules } from '../../../../../containers/detection_engine/rules/use_find_rules_query'; interface UseBulkActionsArgs { @@ -72,7 +61,6 @@ export const useBulkActions = ({ const hasMlPermissions = useHasMlPermissions(); const rulesTableContext = useRulesTableContext(); const invalidateRules = useInvalidateRules(); - const [, dispatchToaster] = useStateToaster(); const hasActionsPrivileges = useHasActionsPrivileges(); const toasts = useAppToasts(); const getIsMounted = useIsMounted(); @@ -117,65 +105,45 @@ export const useBulkActions = ({ const mlRuleCount = disabledRules.length - disabledRulesNoML.length; if (!hasMlPermissions && mlRuleCount > 0) { - displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); + toasts.addWarning(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount)); } const ruleIds = hasMlPermissions ? disabledRules.map(({ id }) => id) : disabledRulesNoML.map(({ id }) => id); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: ruleIds, - action: BulkAction.enable, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await enableRulesAction(ruleIds, true, dispatchToaster, setLoadingRules); - } + await executeRulesBulkAction({ + visibleRuleIds: ruleIds, + action: BulkAction.enable, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: ruleIds }, + }); invalidateRules(); }; const handleDisableActions = async () => { closePopover(); const enabledIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: enabledIds, - action: BulkAction.disable, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await enableRulesAction(enabledIds, false, dispatchToaster, setLoadingRules); - } + await executeRulesBulkAction({ + visibleRuleIds: enabledIds, + action: BulkAction.disable, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: enabledIds }, + }); invalidateRules(); }; const handleDuplicateAction = async () => { closePopover(); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.duplicate, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await duplicateRulesAction( - selectedRules, - selectedRuleIds, - dispatchToaster, - setLoadingRules - ); - } + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.duplicate, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); invalidateRules(); }; @@ -186,39 +154,28 @@ export const useBulkActions = ({ // User has cancelled deletion return; } - - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.delete, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await deleteRulesAction(selectedRuleIds, dispatchToaster, setLoadingRules); } + + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.delete, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); invalidateRules(); }; const handleExportAction = async () => { closePopover(); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.export, - setLoadingRules, - toasts, - }); - await rulesBulkAction.byQuery(filterQuery); - } else { - await exportRulesAction( - selectedRules.map((r) => r.rule_id), - dispatchToaster, - setLoadingRules - ); - } + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.export, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); }; const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => { @@ -288,31 +245,7 @@ export const useBulkActions = ({ setLoadingRules, toasts, payload: { edit: [editPayload] }, - onSuccess: ({ rulesCount }) => { - hideWarningToast(); - toasts.addSuccess({ - title: i18n.BULK_EDIT_SUCCESS_TOAST_TITLE, - text: i18n.BULK_EDIT_SUCCESS_TOAST_DESCRIPTION(rulesCount), - iconType: undefined, - }); - }, - onError: (error: HTTPError) => { - hideWarningToast(); - // if response doesn't have number of failed rules, it means the whole bulk action failed - // and general error toast will be shown. Otherwise - error toast for partial failure - const failedRulesCount = (error?.body as BulkActionResponse)?.attributes?.summary - ?.failed; - - if (isNaN(failedRulesCount)) { - toasts.addError(error, { title: i18n.BULK_ACTION_FAILED }); - } else { - error.stack = JSON.stringify(error.body, null, 2); - toasts.addError(error, { - title: i18n.BULK_EDIT_ERROR_TOAST_TITLE, - toastMessage: i18n.BULK_EDIT_ERROR_TOAST_DESCRIPTION(failedRulesCount), - }); - } - }, + onFinish: () => hideWarningToast(), }); // only edit custom rules, as elastic rule are immutable @@ -477,7 +410,6 @@ export const useBulkActions = ({ loadingRuleIds, hasMlPermissions, invalidateRules, - dispatchToaster, setLoadingRules, toasts, filterQuery, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts index ebd059971b140..30f9db5d8f7e5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts @@ -5,54 +5,11 @@ * 2.0. */ -import { - bucketRulesResponse, - caseInsensitiveSort, - showRulesTable, - getSearchFilters, -} from './helpers'; -import { mockRule, mockRuleError } from './__mocks__/mock'; -import uuid from 'uuid'; -import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; import { Query } from '@elastic/eui'; import { EXCEPTIONS_SEARCH_SCHEMA } from './exceptions/exceptions_search_bar'; +import { caseInsensitiveSort, getSearchFilters, showRulesTable } from './helpers'; describe('AllRulesTable Helpers', () => { - const mockRule1: Readonly = mockRule(uuid.v4()); - const mockRule2: Readonly = mockRule(uuid.v4()); - const mockRuleError1: Readonly = mockRuleError(uuid.v4()); - const mockRuleError2: Readonly = mockRuleError(uuid.v4()); - - describe('bucketRulesResponse', () => { - test('buckets empty response', () => { - const bucketedResponse = bucketRulesResponse([]); - expect(bucketedResponse).toEqual({ rules: [], errors: [] }); - }); - - test('buckets all error response', () => { - const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]); - expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] }); - }); - - test('buckets all success response', () => { - const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]); - expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] }); - }); - - test('buckets mixed success/error response', () => { - const bucketedResponse = bucketRulesResponse([ - mockRule1, - mockRuleError1, - mockRule2, - mockRuleError2, - ]); - expect(bucketedResponse).toEqual({ - rules: [mockRule1, mockRule2], - errors: [mockRuleError1, mockRuleError2], - }); - }); - }); - describe('showRulesTable', () => { test('returns false when rulesCustomInstalled and rulesInstalled are null', () => { const result = showRulesTable({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts index 7ed7d1bae60a6..301e5cbe99b50 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts @@ -7,25 +7,7 @@ import { Query } from '@elastic/eui'; import { ExportRulesDetails } from '../../../../../../common/detection_engine/schemas/response/export_rules_details_schema'; -import { - BulkRuleResponse, - RuleResponseBuckets, -} from '../../../../containers/detection_engine/rules'; - -/** - * Separates rules/errors from bulk rules API response (create/update/delete) - * - * @param response BulkRuleResponse from bulk rules API - */ -export const bucketRulesResponse = (response: BulkRuleResponse) => - response.reduce( - (acc, cv): RuleResponseBuckets => { - return 'error' in cv - ? { rules: [...acc.rules], errors: [...acc.errors, cv] } - : { rules: [...acc.rules, cv], errors: [...acc.errors] }; - }, - { rules: [], errors: [] } - ); +import { BulkActionSummary } from '../../../../containers/detection_engine/rules'; export const showRulesTable = ({ rulesCustomInstalled, @@ -93,12 +75,12 @@ export const getExportedRulesDetails = async (blob: Blob): Promise { +export const getExportedRulesCounts = async (blob: Blob): Promise => { const details = await getExportedRulesDetails(blob); return { - exported: details.exported_rules_count, - missing: details.missing_rules_count, + succeeded: details.exported_rules_count, + failed: details.missing_rules_count, total: details.exported_rules_count + details.missing_rules_count, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx index 44651172a6b26..d1e769f03bc9c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx @@ -6,42 +6,38 @@ */ import uuid from 'uuid'; +import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock'; import '../../../../../common/mock/match_media'; -import { deleteRulesAction, duplicateRulesAction, editRuleAction } from './actions'; +import { goToRuleEditPage, executeRulesBulkAction } from './actions'; import { getRulesTableActions } from './rules_table_actions'; import { mockRule } from './__mocks__/mock'; -jest.mock('./actions', () => ({ - duplicateRulesAction: jest.fn(), - deleteRulesAction: jest.fn(), - editRuleAction: jest.fn(), -})); +jest.mock('./actions'); -const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; -const deleteRulesActionMock = deleteRulesAction as jest.Mock; -const editRuleActionMock = editRuleAction as jest.Mock; +const executeRulesBulkActionMock = executeRulesBulkAction as jest.Mock; +const goToRuleEditPageMock = goToRuleEditPage as jest.Mock; describe('getRulesTableActions', () => { const rule = mockRule(uuid.v4()); - const dispatchToaster = jest.fn(); - const reFetchRules = jest.fn(); + const toasts = useAppToastsMock.create(); + const invalidateRules = jest.fn(); const setLoadingRules = jest.fn(); beforeEach(() => { - duplicateRulesActionMock.mockClear(); - deleteRulesActionMock.mockClear(); - reFetchRules.mockClear(); + jest.clearAllMocks(); }); test('duplicate rule onClick should call rule edit after the rule is duplicated', async () => { const ruleDuplicate = mockRule('newRule'); const navigateToApp = jest.fn(); - duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); + executeRulesBulkActionMock.mockImplementation(() => + Promise.resolve({ attributes: { results: { created: [ruleDuplicate] } } }) + ); const duplicateRulesActionObject = getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, - reFetchRules, + invalidateRules, true, setLoadingRules )[1]; @@ -49,17 +45,19 @@ describe('getRulesTableActions', () => { expect(duplicateRulesActionHandler).toBeDefined(); await duplicateRulesActionHandler!(rule); - expect(duplicateRulesActionMock).toHaveBeenCalled(); - expect(editRuleActionMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); + expect(goToRuleEditPageMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); }); test('delete rule onClick should call refetch after the rule is deleted', async () => { const navigateToApp = jest.fn(); const deleteRulesActionObject = getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, - reFetchRules, + invalidateRules, true, setLoadingRules )[3]; @@ -67,10 +65,13 @@ describe('getRulesTableActions', () => { expect(deleteRuleActionHandler).toBeDefined(); await deleteRuleActionHandler!(rule); - expect(deleteRulesActionMock).toHaveBeenCalledTimes(1); - expect(reFetchRules).toHaveBeenCalledTimes(1); - expect(deleteRulesActionMock.mock.invocationCallOrder[0]).toBeLessThan( - reFetchRules.mock.invocationCallOrder[0] + expect(executeRulesBulkAction).toHaveBeenCalledTimes(1); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete' }) + ); + expect(invalidateRules).toHaveBeenCalledTimes(1); + expect(executeRulesBulkActionMock.mock.invocationCallOrder[0]).toBeLessThan( + invalidateRules.mock.invocationCallOrder[0] ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx index 3c960108fddf8..9f0c0d0cb9695 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx @@ -11,26 +11,22 @@ import { EuiTableActionsColumnType, EuiToolTip, } from '@elastic/eui'; -import React, { Dispatch } from 'react'; +import React from 'react'; import { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; -import { ActionToaster } from '../../../../../common/components/toasters'; +import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; +import { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; import { Rule } from '../../../../containers/detection_engine/rules'; -import { RulesTableActions } from './rules_table/rules_table_context'; import * as i18n from '../translations'; -import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, - exportRulesAction, -} from './actions'; +import { executeRulesBulkAction, goToRuleEditPage } from './actions'; +import { RulesTableActions } from './rules_table/rules_table_context'; type NavigateToApp = (appId: string, options?: NavigateToAppOptions | undefined) => Promise; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; export const getRulesTableActions = ( - dispatchToaster: Dispatch, + toasts: UseAppToasts, navigateToApp: NavigateToApp, invalidateRules: () => void, actionsPrivileges: boolean, @@ -48,7 +44,7 @@ export const getRulesTableActions = ( i18n.EDIT_RULE_SETTINGS ), icon: 'controlsHorizontal', - onClick: (rule: Rule) => editRuleAction(rule.id, navigateToApp), + onClick: (rule: Rule) => goToRuleEditPage(rule.id, navigateToApp), enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), }, { @@ -65,15 +61,17 @@ export const getRulesTableActions = ( ), enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), onClick: async (rule: Rule) => { - const createdRules = await duplicateRulesAction( - [rule], - [rule.id], - dispatchToaster, - setLoadingRules - ); + const result = await executeRulesBulkAction({ + action: BulkAction.duplicate, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }); invalidateRules(); + const createdRules = result?.attributes.results.created; if (createdRules?.length) { - editRuleAction(createdRules[0].id, navigateToApp); + goToRuleEditPage(createdRules[0].id, navigateToApp); } }, }, @@ -83,7 +81,14 @@ export const getRulesTableActions = ( description: i18n.EXPORT_RULE, icon: 'exportAction', name: i18n.EXPORT_RULE, - onClick: (rule: Rule) => exportRulesAction([rule.rule_id], dispatchToaster, setLoadingRules), + onClick: (rule: Rule) => + executeRulesBulkAction({ + action: BulkAction.export, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }), enabled: (rule: Rule) => !rule.immutable, }, { @@ -93,7 +98,13 @@ export const getRulesTableActions = ( icon: 'trash', name: i18n.DELETE_RULE, onClick: async (rule: Rule) => { - await deleteRulesAction([rule.id], dispatchToaster, setLoadingRules); + await executeRulesBulkAction({ + action: BulkAction.delete, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }); invalidateRules(); }, }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index 3788203008238..5882cc9a72d9a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -27,7 +27,6 @@ import { LinkAnchor } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { PopoverItems } from '../../../../../common/components/popover_items'; -import { useStateToaster } from '../../../../../common/components/toasters'; import { useKibana } from '../../../../../common/lib/kibana'; import { canEditRuleWithActions, getToolTipContent } from '../../../../../common/utils/privileges'; import { RuleSwitch } from '../../../../components/rules/rule_switch'; @@ -45,6 +44,7 @@ import { DurationMetric, RuleExecutionSummary, } from '../../../../../../common/detection_engine/schemas/common'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; @@ -160,13 +160,13 @@ const TAGS_COLUMN: TableColumn = { const useActionsColumn = (): EuiTableActionsColumnType => { const { navigateToApp } = useKibana().services.application; const hasActionsPrivileges = useHasActionsPrivileges(); - const [, dispatchToaster] = useStateToaster(); + const toasts = useAppToasts(); const { reFetchRules, setLoadingRules } = useRulesTableContext().actions; return useMemo( () => ({ actions: getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, reFetchRules, hasActionsPrivileges, @@ -174,7 +174,7 @@ const useActionsColumn = (): EuiTableActionsColumnType => { ), width: '40px', }), - [dispatchToaster, hasActionsPrivileges, navigateToApp, reFetchRules, setLoadingRules] + [hasActionsPrivileges, navigateToApp, reFetchRules, setLoadingRules, toasts] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index b1cc2e4f0388c..3a9f233d9bffb 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -244,23 +244,6 @@ export const BULK_ACTION_MENU_TITLE = i18n.translate( } ); -export const BULK_EDIT_SUCCESS_TOAST_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle', - { - defaultMessage: 'Rules changes updated', - } -); - -export const BULK_EDIT_SUCCESS_TOAST_DESCRIPTION = (rulesCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription', - { - values: { rulesCount }, - defaultMessage: - 'You’ve successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.', - } - ); - export const BULK_EDIT_WARNING_TOAST_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle', { @@ -284,22 +267,6 @@ export const BULK_EDIT_WARNING_TOAST_NOTIFY = i18n.translate( } ); -export const BULK_EDIT_ERROR_TOAST_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle', - { - defaultMessage: 'Rule updates failed', - } -); - -export const BULK_EDIT_ERROR_TOAST_DESCRIPTION = (rulesCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription', - { - values: { rulesCount }, - defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to update.', - } - ); - export const BULK_EDIT_CONFIRMATION_TITLE = (elasticRulesCount: number) => i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle', @@ -454,24 +421,6 @@ export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_TITLE = i18n.translate( } ); -export const BATCH_ACTION_ENABLE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.enableSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error enabling {totalRules, plural, =1 {rule} other {rules}}', - } - ); - -export const BATCH_ACTION_DISABLE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.disableSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error disabling {totalRules, plural, =1 {rule} other {rules}}', - } - ); - export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', { @@ -479,15 +428,6 @@ export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( } ); -export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}', - } - ); - export const EXPORT_FILENAME = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.exportFilenameTitle', { @@ -495,16 +435,6 @@ export const EXPORT_FILENAME = i18n.translate( } ); -export const SUCCESSFULLY_EXPORTED_RULES = (exportedRules: number, totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle', - { - values: { totalRules, exportedRules }, - defaultMessage: - 'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.', - } - ); - export const ALL_RULES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.tableTitle', { @@ -579,30 +509,6 @@ export const DUPLICATE_RULE = i18n.translate( } ); -export const SUCCESSFULLY_DUPLICATED_RULES = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle', - { - values: { totalRules }, - defaultMessage: - 'Successfully duplicated {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', - } - ); - -export const DUPLICATE_RULE_ERROR = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription', - { - defaultMessage: 'Error duplicating rule', - } -); - -export const BULK_ACTION_FAILED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription', - { - defaultMessage: 'Failed to execute bulk action', - } -); - export const EXPORT_RULE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription', { @@ -611,7 +517,7 @@ export const EXPORT_RULE = i18n.translate( ); export const DELETE_RULE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription', + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription', { defaultMessage: 'Delete rule', } @@ -992,3 +898,226 @@ export const SHOWING_EXCEPTION_LISTS = (totalLists: number) => values: { totalLists }, defaultMessage: 'Showing {totalLists} {totalLists, plural, =1 {list} other {lists}}', }); + +/** + * Bulk Export + */ + +export const RULES_BULK_EXPORT_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastTitle', + { + defaultMessage: 'Rules exported', + } +); + +export const RULES_BULK_EXPORT_SUCCESS_DESCRIPTION = (exportedRules: number, totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription', + { + values: { totalRules, exportedRules }, + defaultMessage: + 'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.', + } + ); + +export const RULES_BULK_EXPORT_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastTitle', + { + defaultMessage: 'Error exporting rules', + } +); + +export const RULES_BULK_EXPORT_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to export.', + } + ); + +/** + * Bulk Duplicate + */ + +export const RULES_BULK_DUPLICATE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastTitle', + { + defaultMessage: 'Rules duplicated', + } +); + +export const RULES_BULK_DUPLICATE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully duplicated {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DUPLICATE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle', + { + defaultMessage: 'Error duplicating rule', + } +); + +export const RULES_BULK_DUPLICATE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: + '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to duplicate.', + } + ); + +/** + * Bulk Delete + */ + +export const RULES_BULK_DELETE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.successToastTitle', + { + defaultMessage: 'Rules deleted', + } +); + +export const RULES_BULK_DELETE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully deleted {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DELETE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.errorToastTitle', + { + defaultMessage: 'Error deleting rules', + } +); + +export const RULES_BULK_DELETE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to delete.', + } + ); + +/** + * Bulk Enable + */ + +export const RULES_BULK_ENABLE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.successToastTitle', + { + defaultMessage: 'Rules enabled', + } +); + +export const RULES_BULK_ENABLE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkAction.enable.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully enabled {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_ENABLE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.errorToastTitle', + { + defaultMessage: 'Error enabling rules', + } +); + +export const RULES_BULK_ENABLE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to enable.', + } + ); + +/** + * Bulk Disable + */ + +export const RULES_BULK_DISABLE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastTitle', + { + defaultMessage: 'Rules disabled', + } +); + +export const RULES_BULK_DISABLE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully disabled {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DISABLE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.errorToastTitle', + { + defaultMessage: 'Error disabling rules', + } +); + +export const RULES_BULK_DISABLE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to disable.', + } + ); + +/** + * Bulk Edit + */ + +export const RULES_BULK_EDIT_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle', + { + defaultMessage: 'Rules updated', + } +); + +export const RULES_BULK_EDIT_SUCCESS_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription', + { + values: { rulesCount }, + defaultMessage: + "You've successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.", + } + ); + +export const RULES_BULK_EDIT_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastTitle', + { + defaultMessage: 'Error updating rules', + } +); + +export const RULES_BULK_EDIT_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} failed to update.', + } + ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index 1e1c894ad097c..d8978cd8b11aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -95,13 +95,19 @@ const buildBulkResponse = ( total: bulkActionOutcome.results.length + errors.length, }; + // Whether rules will be updated, created or deleted depends on the bulk + // action type being processed. However, in order to avoid doing a switch-case + // by the action type, we can figure it out indirectly. const results = { + // We had a rule, now there's a rule with the same id - the existing rule was modified updated: bulkActionOutcome.results .filter(({ item, result }) => item.id === result?.id) .map(({ result }) => result && internalRuleToAPIResponse(result)), + // We had a rule, now there's a rule with a different id - a new rule was created created: bulkActionOutcome.results .filter(({ item, result }) => result != null && result.id !== item.id) .map(({ result }) => result && internalRuleToAPIResponse(result)), + // We had a rule, now it's null - the rule was deleted deleted: bulkActionOutcome.results .filter(({ result }) => result == null) .map(({ item }) => internalRuleToAPIResponse(item)), diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4a9913fe97aba..ac95693301e54 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -20812,16 +20812,14 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "Rechercher les listes d'exceptions", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "Nous n'avons trouvé aucune liste d'exceptions.", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "Exceptions", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "Impossible d'exécuter l'action groupée", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "Supprimer la règle", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "Supprimer la règle", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "Dupliquer la règle", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "Erreur lors de la duplication de la règle", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "Erreur lors de la duplication de la règle", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "Dupliquer", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "Modifier les paramètres de règles", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "Vous ne disposez pas des privilèges d'actions Kibana", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "Exporter la règle", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "active", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "Erreur lors de la suppression de {totalRules, plural, =1 {règle} other {règles}}", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "La sélection contient des règles immuables qui ne peuvent pas être supprimées", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "Actions groupées", "xpack.securitySolution.detectionEngine.rules.allRules.clearSelectionTitle": "Effacer la sélection", @@ -20852,8 +20850,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "Sélection de {selectedRules} {selectedRules, plural, =1 {règle} other {règles}} effectuée", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "Affichage de {totalLists} {totalLists, plural, =1 {liste} other {listes}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "Affichage de {totalRules} {totalRules, plural, =1 {règle} other {règles}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "Toutes les règles", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "Listes d'exceptions", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "Monitoring des règles", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1dea85f7f1499..2337f25502da3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23808,27 +23808,24 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "検索例外リスト", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "例外リストが見つかりませんでした。", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "例外リスト", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "一括アクションを実行できませんでした", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "ルールの削除", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "ルールの削除", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "ルールの複製", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "ルールの複製エラー", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "ルールの複製エラー", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "複製", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "ルール設定の編集", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "Kibana アクション特権がありません", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "ルールのエクスポート", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "アクティブ", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "{totalRules, plural, other {ルール}}の削除エラー", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "選択には削除できないイミュータブルルールがあります", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "一斉アクション", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addIndexPatternsTitle": "インデックスパターンを追加", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addTagsTitle": "タグを追加", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDescription": "更新アクションは、選択した{customRulesCount, plural, other {#個のカスタムルール}}にのみ適用されます。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle": "{elasticRulesCount, plural, other {#個のElasticルール}}を編集できません", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription": "{rulesCount, plural, other {#個のルール}}を更新できませんでした。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle": "ルールの更新が失敗しました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription": "{rulesCount, plural, other {#個のルール}}を更新できませんでした。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditRejectionDescription": "Elasticルールは変更できません。更新アクションはカスタムルールにのみ適用されます。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription": "{rulesCount, plural, other {#個のルール}}を正常に更新しました。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle": "ルール変更が更新されました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription": "{rulesCount, plural, other {#個のルール}}を正常に更新しました。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle": "ルール変更が更新されました", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastDescription": "{rulesCount, plural, other {#個のルール}}を更新しています。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastNotifyButtonLabel": "完了時に通知", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle": "ルールを更新しています", @@ -23874,8 +23871,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "{selectedRules} {selectedRules, plural, other {ルール}}を選択しました", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "{totalLists} {totalLists, plural, other {件のリスト}}を表示しています。", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "{totalRules} {totalRules, plural, other {ルール}}を表示中", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "すべてのルール", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外リスト", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "ルール監視", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fabf4d7a2d590..158534694943a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23837,27 +23837,24 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "搜索例外列表", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "我们找不到任何例外列表。", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "例外列表", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "无法执行批量操作", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "删除规则", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "删除规则", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "复制规则", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "复制规则时出错", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "复制规则时出错", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "复制", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "编辑规则设置", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "您没有 Kibana 操作权限", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "导出规则", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "活动", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "删除{totalRules, plural, other {规则}}时出错", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "选择内容包含无法删除的不可变规则", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "批处理操作", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addIndexPatternsTitle": "添加索引模式", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addTagsTitle": "添加标签", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDescription": "将仅对您选定的 {customRulesCount, plural, other {# 个定制规则}}应用更新操作。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle": "无法编辑 {elasticRulesCount, plural, other {# 个 Elastic 规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription": "无法更新 {rulesCount, plural, other {# 个规则}}。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle": "规则更新失败", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription": "无法更新 {rulesCount, plural, other {# 个规则}}。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditRejectionDescription": "无法修改 Elastic 规则。将仅对定制规则应用更新操作。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription": "您已成功更新 {rulesCount, plural, other {# 个规则}}。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle": "规则更改已更新", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription": "您已成功更新 {rulesCount, plural, other {# 个规则}}。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle": "规则更改已更新", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastDescription": "{rulesCount, plural, other {# 个规则}}正在更新。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastNotifyButtonLabel": "在完成时通知我", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle": "正在进行规则更新", @@ -23903,8 +23900,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "已选择 {selectedRules} 个{selectedRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "正在显示 {totalLists} 个{totalLists, plural, other {列表}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "所有规则", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外列表", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "规则监测", From 46a4da2cc27ddfabfd48eb354d40f6994ba43102 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Wed, 23 Mar 2022 09:38:24 -0400 Subject: [PATCH 06/66] [Security Solution][Analyzer] Updates the selector used on the process button to match panel (#127401) --- .../public/resolver/view/process_event_dot.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 214f8eba0eec8..f6064fe54f6db 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -18,6 +18,8 @@ import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import { SideEffectContext } from './side_effect_context'; import * as nodeModel from '../../../common/endpoint/models/node'; +import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeDataModel from '../models/node_data'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; @@ -327,8 +329,18 @@ const UnstyledProcessEventDot = React.memo( const grandTotal: number | null = useSelector((state: ResolverState) => selectors.statsTotalForNode(state)(node) ); - const nodeName = nodeModel.nodeName(node); + const processEvent = useSelector((state: ResolverState) => + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(String(node.id))) + ); + const processName = useMemo(() => { + if (processEvent !== undefined) { + return eventModel.processNameSafeVersion(processEvent); + } else { + return nodeName; + } + }, [processEvent, nodeName]); + /* eslint-disable jsx-a11y/click-events-have-key-events */ return (
@@ -476,7 +488,7 @@ const UnstyledProcessEventDot = React.memo( defaultMessage: `{nodeState, select, error {Reload {nodeName}} other {{nodeName}}}`, values: { nodeState, - nodeName, + nodeName: processName, }, })} From cf538aaa28d93f87f183149493e96776496340c9 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 23 Mar 2022 13:40:33 +0000 Subject: [PATCH 07/66] skip flaky suite (#128332) --- x-pack/test/accessibility/apps/maps.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/maps.ts b/x-pack/test/accessibility/apps/maps.ts index 079972273c19b..1eb4ad433c661 100644 --- a/x-pack/test/accessibility/apps/maps.ts +++ b/x-pack/test/accessibility/apps/maps.ts @@ -119,7 +119,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('single cancel modal', async function () { + // FLAKY: https://github.com/elastic/kibana/issues/128332 + it.skip('single cancel modal', async function () { await testSubjects.click('confirmModalCancelButton'); await a11y.testAppSnapshot(); }); From 27e170a6649367137a241b24f5fef8940a3712bd Mon Sep 17 00:00:00 2001 From: Sandra G Date: Wed, 23 Mar 2022 09:45:31 -0400 Subject: [PATCH 08/66] [Stack Monitoring] fix sorting by node status on nodes listing page (#128323) * fix sorting by node status * fix type * code cleanup --- .../nodes/get_nodes/get_paginated_nodes.ts | 11 +++++++--- .../apps/monitoring/elasticsearch/nodes_mb.js | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts index 7f250289cf3b6..541320e8499f5 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts @@ -14,6 +14,7 @@ import { sortNodes } from './sort_nodes'; import { paginate } from '../../../pagination/paginate'; import { getMetrics } from '../../../details/get_metrics'; import { LegacyRequest } from '../../../../types'; +import { ElasticsearchModifiedSource } from '../../../../../common/types/es'; /** * This function performs an optimization around the node listing tables in the UI. To avoid @@ -51,7 +52,8 @@ export async function getPaginatedNodes( nodesShardCount, }: { clusterStats: { - cluster_state: { nodes: Record }; + cluster_state?: { nodes: Record }; + elasticsearch?: ElasticsearchModifiedSource['elasticsearch']; }; nodesShardCount: { nodes: Record }; } @@ -61,9 +63,12 @@ export async function getPaginatedNodes( const nodes: Node[] = await getNodeIds(req, { clusterUuid }, size); // Add `isOnline` and shards from the cluster state and shard stats - const clusterState = clusterStats?.cluster_state ?? { nodes: {} }; + const clusterStateNodes = + clusterStats?.cluster_state?.nodes ?? + clusterStats?.elasticsearch?.cluster?.stats?.state?.nodes ?? + {}; for (const node of nodes) { - node.isOnline = !isUndefined(clusterState?.nodes[node.uuid]); + node.isOnline = !isUndefined(clusterStateNodes && clusterStateNodes[node.uuid]); node.shardCount = nodesShardCount?.nodes[node.uuid]?.shardCount ?? 0; } diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js index aa12619ca447c..059e18bc865ff 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js @@ -189,19 +189,21 @@ export default function ({ getService, getPageObjects }) { }); }); - // this is actually broken, see https://github.com/elastic/kibana/issues/122338 - it.skip('should sort by status', async () => { - const sortedStatusesAscending = ['Status: Offline', 'Status: Online', 'Status: Online']; - const sortedStatusesDescending = [...sortedStatusesAscending].reverse(); - + it('should sort by status', async () => { await nodesList.clickStatusCol(); - await retry.try(async () => { - expect(await nodesList.getNodeStatuses()).to.eql(sortedStatusesDescending); - }); - await nodesList.clickStatusCol(); + + // retry in case the table hasn't had time to re-render await retry.try(async () => { - expect(await nodesList.getNodeStatuses()).to.eql(sortedStatusesAscending); + const nodesAll = await nodesList.getNodesAll(); + const tableData = [ + { status: 'Status: Online' }, + { status: 'Status: Online' }, + { status: 'Status: Offline' }, + ]; + nodesAll.forEach((obj, node) => { + expect(nodesAll[node].status).to.be(tableData[node].status); + }); }); }); From 7c31c8749654ea4cfc895caeb956e6dd9f493f30 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Wed, 23 Mar 2022 15:05:37 +0100 Subject: [PATCH 09/66] [Expressions] Return total hits count in the `datatable` metadata (#127316) * Add datatable metadata support * Fix datatable-based expressions to preserve metadata * Update ES expression functions to return hits total count in the metadata --- .../datatable_utilities_service.test.ts | 12 ++++++- .../datatable_utilities_service.ts | 6 +++- .../eql_raw_response.test.ts.snap | 6 ++++ .../es_raw_response.test.ts.snap | 9 +++++ .../expressions/eql_raw_response.test.ts | 16 +++++++++ .../search/expressions/eql_raw_response.ts | 3 ++ .../search/expressions/es_raw_response.ts | 6 ++++ .../data/common/search/tabify/tabify.test.ts | 4 +++ .../data/common/search/tabify/tabify.ts | 14 ++++++-- .../expression_functions/specs/map_column.ts | 4 +-- .../expression_functions/specs/math_column.ts | 4 +-- .../expression_types/specs/datatable.ts | 33 +++++++++++++++++++ .../snapshots/baseline/combined_test2.json | 2 +- .../snapshots/baseline/combined_test3.json | 2 +- .../snapshots/baseline/final_output_test.json | 2 +- .../snapshots/baseline/metric_all_data.json | 2 +- .../snapshots/baseline/metric_empty_data.json | 2 +- .../baseline/metric_multi_metric_data.json | 2 +- .../baseline/metric_percentage_mode.json | 2 +- .../baseline/metric_single_metric_data.json | 2 +- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/partial_test_2.json | 2 +- .../snapshots/baseline/step_output_test2.json | 2 +- .../snapshots/baseline/step_output_test3.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../baseline/tagcloud_empty_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- .../snapshots/session/combined_test2.json | 2 +- .../snapshots/session/combined_test3.json | 2 +- .../snapshots/session/final_output_test.json | 2 +- .../snapshots/session/metric_all_data.json | 2 +- .../snapshots/session/metric_empty_data.json | 2 +- .../session/metric_multi_metric_data.json | 2 +- .../session/metric_percentage_mode.json | 2 +- .../session/metric_single_metric_data.json | 2 +- .../snapshots/session/partial_test_1.json | 2 +- .../snapshots/session/partial_test_2.json | 2 +- .../snapshots/session/step_output_test2.json | 2 +- .../snapshots/session/step_output_test3.json | 2 +- .../snapshots/session/tagcloud_all_data.json | 2 +- .../session/tagcloud_empty_data.json | 2 +- .../snapshots/session/tagcloud_fontsize.json | 2 +- .../session/tagcloud_metric_data.json | 2 +- .../snapshots/session/tagcloud_options.json | 2 +- .../functions/common/alterColumn.ts | 2 +- .../canvas_plugin_src/functions/common/ply.ts | 2 +- .../functions/common/staticColumn.ts | 2 +- .../rename_columns/rename_columns_fn.ts | 2 +- 50 files changed, 147 insertions(+), 46 deletions(-) diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts index d626bc2226543..0a178a78f1e22 100644 --- a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts @@ -8,7 +8,7 @@ import { createStubDataView } from 'src/plugins/data_views/common/mocks'; import type { DataViewsContract } from 'src/plugins/data_views/common'; -import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/common'; import { FieldFormat } from 'src/plugins/field_formats/common'; import { fieldFormatsMock } from 'src/plugins/field_formats/common/mocks'; import type { AggsCommonStart } from '../search'; @@ -106,6 +106,16 @@ describe('DatatableUtilitiesService', () => { }); }); + describe('getTotalCount', () => { + it('should return a total hits count', () => { + const table = { + meta: { statistics: { totalCount: 100 } }, + } as unknown as Datatable; + + expect(datatableUtilitiesService.getTotalCount(table)).toBe(100); + }); + }); + describe('setFieldFormat', () => { it('should set new field format', () => { const column = { meta: {} } as DatatableColumn; diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts index cf4e65f31cce3..7430b3f6bc09c 100644 --- a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts @@ -7,7 +7,7 @@ */ import type { DataView, DataViewsContract, DataViewField } from 'src/plugins/data_views/common'; -import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/common'; import type { FieldFormatsStartCommon, FieldFormat } from 'src/plugins/field_formats/common'; import type { AggsCommonStart, AggConfig, CreateAggConfigParams, IAggType } from '../search'; @@ -77,6 +77,10 @@ export class DatatableUtilitiesService { return params?.interval; } + getTotalCount(table: Datatable): number | undefined { + return table.meta?.statistics?.totalCount; + } + isFilterable(column: DatatableColumn): boolean { if (column.meta.source !== 'esaggs') { return false; diff --git a/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap b/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap index 341a04cef373f..ef62a04c301e7 100644 --- a/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap +++ b/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap @@ -24,6 +24,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": undefined, + }, "type": "eql", }, "rows": Array [ @@ -145,6 +148,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": undefined, + }, "type": "eql", }, "rows": Array [ diff --git a/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap b/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap index c43663a50a2ba..89f26aeee7aaf 100644 --- a/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap +++ b/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap @@ -42,6 +42,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ @@ -86,6 +89,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ @@ -172,6 +178,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ diff --git a/src/plugins/data/common/search/expressions/eql_raw_response.test.ts b/src/plugins/data/common/search/expressions/eql_raw_response.test.ts index 80d7ca25c89df..d0fee449d2b00 100644 --- a/src/plugins/data/common/search/expressions/eql_raw_response.test.ts +++ b/src/plugins/data/common/search/expressions/eql_raw_response.test.ts @@ -43,6 +43,22 @@ describe('eqlRawResponse', () => { const result = eqlRawResponse.to!.datatable(response, {}); expect(result).toMatchSnapshot(); }); + + test('extracts total hits number', () => { + const response: EqlRawResponse = { + type: 'eql_raw_response', + body: { + hits: { + events: [], + total: { + value: 2, + }, + }, + }, + }; + const result = eqlRawResponse.to!.datatable(response, {}); + expect(result).toHaveProperty('meta.statistics.totalCount', 2); + }); }); describe('converts sequences to table', () => { diff --git a/src/plugins/data/common/search/expressions/eql_raw_response.ts b/src/plugins/data/common/search/expressions/eql_raw_response.ts index 64e41332a8c17..ccdaed47e1676 100644 --- a/src/plugins/data/common/search/expressions/eql_raw_response.ts +++ b/src/plugins/data/common/search/expressions/eql_raw_response.ts @@ -125,6 +125,9 @@ export const eqlRawResponse: EqlRawResponseExpressionTypeDefinition = { meta: { type: 'eql', source: '*', + statistics: { + totalCount: (context.body as EqlSearchResponse).hits.total?.value, + }, }, columns, rows, diff --git a/src/plugins/data/common/search/expressions/es_raw_response.ts b/src/plugins/data/common/search/expressions/es_raw_response.ts index 61d79939e8635..97c685777e4c7 100644 --- a/src/plugins/data/common/search/expressions/es_raw_response.ts +++ b/src/plugins/data/common/search/expressions/es_raw_response.ts @@ -82,6 +82,12 @@ export const esRawResponse: EsRawResponseExpressionTypeDefinition = { meta: { type: 'esdsl', source: '*', + statistics: { + totalCount: + typeof context.body.hits.total === 'number' + ? context.body.hits.total + : context.body.hits.total?.value, + }, }, columns, rows, diff --git a/src/plugins/data/common/search/tabify/tabify.test.ts b/src/plugins/data/common/search/tabify/tabify.test.ts index 3e1b856de4100..1f4d23a897c6e 100644 --- a/src/plugins/data/common/search/tabify/tabify.test.ts +++ b/src/plugins/data/common/search/tabify/tabify.test.ts @@ -52,6 +52,10 @@ describe('tabifyAggResponse Integration', () => { expect(resp.rows[0]).toEqual({ 'col-0-1': 1000 }); expect(resp.columns[0]).toHaveProperty('name', aggConfigs.aggs[0].makeLabel()); + + expect(resp).toHaveProperty('meta.type', 'esaggs'); + expect(resp).toHaveProperty('meta.source', '1234'); + expect(resp).toHaveProperty('meta.statistics.totalCount', 1000); }); describe('scaleMetricValues performance check', () => { diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 7bc02ce353d53..a640e75bac3c4 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -7,6 +7,7 @@ */ import { get } from 'lodash'; +import type { Datatable } from 'src/plugins/expressions'; import { TabbedAggResponseWriter } from './response_writer'; import { TabifyBuckets } from './buckets'; import type { TabbedResponseWriterOptions } from './types'; @@ -20,7 +21,7 @@ export function tabifyAggResponse( aggConfigs: IAggConfigs, esResponse: Record, respOpts?: Partial -) { +): Datatable { /** * read an aggregation from a bucket, which *might* be found at key (if * the response came in object form), and will recurse down the aggregation @@ -152,5 +153,14 @@ export function tabifyAggResponse( collectBucket(aggConfigs, write, topLevelBucket, '', 1); - return write.response(); + return { + ...write.response(), + meta: { + type: 'esaggs', + source: aggConfigs.indexPattern.id, + statistics: { + totalCount: esResponse.hits?.total, + }, + }, + }; } diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts index 7b2266637bfb5..c3a267d9dca6c 100644 --- a/src/plugins/expressions/common/expression_functions/specs/map_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -95,7 +95,7 @@ export const mapColumn: ExpressionFunctionDefinition< input.rows.map((row) => args .expression({ - type: 'datatable', + ...input, columns: [...input.columns], rows: [row], }) @@ -129,9 +129,9 @@ export const mapColumn: ExpressionFunctionDefinition< }; return { + ...input, columns, rows, - type: 'datatable', }; }) ); diff --git a/src/plugins/expressions/common/expression_functions/specs/math_column.ts b/src/plugins/expressions/common/expression_functions/specs/math_column.ts index ae6cc8b755fe1..b513ef5d27409 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math_column.ts @@ -79,7 +79,7 @@ export const mathColumn: ExpressionFunctionDefinition< input.rows.map(async (row) => { const result = await math.fn( { - type: 'datatable', + ...input, columns: input.columns, rows: [row], }, @@ -128,7 +128,7 @@ export const mathColumn: ExpressionFunctionDefinition< columns.push(newColumn); return { - type: 'datatable', + ...input, columns, rows: newRows, } as Datatable; diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index a07f103d12e06..2a4820508210d 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -91,12 +91,45 @@ export interface DatatableColumn { meta: DatatableColumnMeta; } +/** + * Metadata with statistics about the `Datatable` source. + */ +export interface DatatableMetaStatistics { + /** + * Total hits number returned for the request generated the `Datatable`. + */ + totalCount?: number; +} + +/** + * The `Datatable` meta information. + */ +export interface DatatableMeta { + /** + * Statistics about the `Datatable` source. + */ + statistics?: DatatableMetaStatistics; + + /** + * The `Datatable` type (e.g. `essql`, `eql`, `esdsl`, etc.). + */ + type?: string; + + /** + * The `Datatable` data source. + */ + source?: string; + + [key: string]: unknown; +} + /** * A `Datatable` in Canvas is a unique structure that represents tabulated data. */ export interface Datatable { type: typeof name; columns: DatatableColumn[]; + meta?: DatatableMeta; rows: DatatableRow[]; } diff --git a/test/interpreter_functional/snapshots/baseline/combined_test2.json b/test/interpreter_functional/snapshots/baseline/combined_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test2.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/combined_test3.json b/test/interpreter_functional/snapshots/baseline/combined_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test3.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/final_output_test.json b/test/interpreter_functional/snapshots/baseline/final_output_test.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/final_output_test.json +++ b/test/interpreter_functional/snapshots/baseline/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_all_data.json b/test/interpreter_functional/snapshots/baseline/metric_all_data.json index 939e51b619928..bd0c93b1de057 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json index 6adb4e117d2c7..e38a14fe2b57e 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json index 4a324a133c057..306c5f40b3d25 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json index 944820d0ed16d..01fe67d1e6a15 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json index 392649d410e15..bf2ddf5e6e184 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/partial_test_1.json index 8ce0ee16a0b3b..0e26456967962 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_2.json b/test/interpreter_functional/snapshots/baseline/partial_test_2.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_2.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test2.json b/test/interpreter_functional/snapshots/baseline/step_output_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test2.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test3.json b/test/interpreter_functional/snapshots/baseline/step_output_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test3.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json index 837251a438911..d373194db261d 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index 5c3ca14f4eab7..864aa3538477e 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json index 5e99024d6e52b..461bdae0e172c 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json index e00233197bda3..4eb2297db5425 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json index 759b2752f9328..d7892c9197b7f 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test2.json b/test/interpreter_functional/snapshots/session/combined_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/session/combined_test2.json +++ b/test/interpreter_functional/snapshots/session/combined_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test3.json b/test/interpreter_functional/snapshots/session/combined_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/combined_test3.json +++ b/test/interpreter_functional/snapshots/session/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/final_output_test.json b/test/interpreter_functional/snapshots/session/final_output_test.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/final_output_test.json +++ b/test/interpreter_functional/snapshots/session/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_all_data.json b/test/interpreter_functional/snapshots/session/metric_all_data.json index 939e51b619928..bd0c93b1de057 100644 --- a/test/interpreter_functional/snapshots/session/metric_all_data.json +++ b/test/interpreter_functional/snapshots/session/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_empty_data.json b/test/interpreter_functional/snapshots/session/metric_empty_data.json index 6adb4e117d2c7..e38a14fe2b57e 100644 --- a/test/interpreter_functional/snapshots/session/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/session/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json index 4a324a133c057..306c5f40b3d25 100644 --- a/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_percentage_mode.json b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json index 944820d0ed16d..01fe67d1e6a15 100644 --- a/test/interpreter_functional/snapshots/session/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json index 392649d410e15..bf2ddf5e6e184 100644 --- a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_1.json b/test/interpreter_functional/snapshots/session/partial_test_1.json index 8ce0ee16a0b3b..0e26456967962 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_1.json +++ b/test/interpreter_functional/snapshots/session/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_2.json b/test/interpreter_functional/snapshots/session/partial_test_2.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_2.json +++ b/test/interpreter_functional/snapshots/session/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test2.json b/test/interpreter_functional/snapshots/session/step_output_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/session/step_output_test2.json +++ b/test/interpreter_functional/snapshots/session/step_output_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test3.json b/test/interpreter_functional/snapshots/session/step_output_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/step_output_test3.json +++ b/test/interpreter_functional/snapshots/session/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_all_data.json b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json index 837251a438911..d373194db261d 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json index 5c3ca14f4eab7..864aa3538477e 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json index 5e99024d6e52b..461bdae0e172c 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json index e00233197bda3..4eb2297db5425 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_options.json b/test/interpreter_functional/snapshots/session/tagcloud_options.json index 759b2752f9328..d7892c9197b7f 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts index b495eb170b5b6..77bf11c71a35a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts @@ -101,7 +101,7 @@ export function alterColumn(): ExpressionFunctionDefinition< })); return { - type: 'datatable', + ...input, columns, rows, }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts index 5ab7b95f0d00b..1a38dfa84fd7e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts @@ -85,7 +85,7 @@ export function ply(): ExpressionFunctionDefinition<'ply', Datatable, Arguments, ); return { - type: 'datatable', + ...input, rows, columns, } as Datatable; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts index 373a9504712d6..032e7298e02f1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts @@ -58,7 +58,7 @@ export function staticColumn(): ExpressionFunctionDefinition< } return { - type: 'datatable', + ...input, columns, rows, }; diff --git a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts index ee0c7ed1eebec..c201c5f8f07e1 100644 --- a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts +++ b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts @@ -28,7 +28,7 @@ export const renameColumnFn: RenameColumnsExpressionFunction['fn'] = ( const idMap = JSON.parse(encodedIdMap) as Record; return { - type: 'datatable', + ...data, rows: data.rows.map((row) => { const mappedRow: Record = {}; Object.entries(idMap).forEach(([fromId, toId]) => { From 75f8ac424e5f58a3c7cdce4f4dcffdae559b10c2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Mar 2022 08:20:10 -0600 Subject: [PATCH 10/66] [Maps] Add support for geohex_grid aggregation (#127170) * [Maps] hex bin gridding * remove console.log * disable hexbins for license and geo_shape * fix jest tests * copy cleanup * label * update clusters SVG with hexbins * show as tooltip * documenation updates * copy updates * add API test for hex * test cleanup * eslint * eslint and functional test fixes * eslint, copy updates, and more doc updates * fix i18n error * consolidate isMvt logic * copy review feedback * use 3 stop scale for hexs * jest snapshot updates * Update x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx Co-authored-by: Nick Peihl Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Nick Peihl --- docs/maps/maps-aggregations.asciidoc | 18 +- docs/maps/maps-getting-started.asciidoc | 2 +- docs/maps/vector-layer.asciidoc | 2 +- x-pack/plugins/maps/common/constants.ts | 1 + x-pack/plugins/maps/kibana.json | 1 + .../wizards/icons/clusters_layer_icon.tsx | 35 ++-- .../resolution_editor.test.tsx.snap | 154 +++++++++++++++++- .../update_source_editor.test.tsx.snap | 6 +- .../clusters_layer_wizard.tsx | 106 ++++++------ .../create_source_editor.js | 17 +- .../es_geo_grid_source.test.ts | 2 +- .../es_geo_grid_source/es_geo_grid_source.tsx | 45 +++-- .../sources/es_geo_grid_source/is_mvt.ts | 23 +++ .../es_geo_grid_source/render_as_select.tsx | 68 -------- .../render_as_select/i18n_constants.ts | 23 +++ .../render_as_select/index.ts | 8 + .../render_as_select/render_as_select.tsx | 92 +++++++++++ .../render_as_select/show_as_label.tsx | 64 ++++++++ .../resolution_editor.test.tsx | 22 ++- .../es_geo_grid_source/resolution_editor.tsx | 127 ++++++++------- .../update_source_editor.test.tsx | 1 + .../update_source_editor.tsx | 56 +++++-- .../classes/sources/es_source/es_source.ts | 2 +- x-pack/plugins/maps/public/kibana_services.ts | 6 + x-pack/plugins/maps/public/plugin.ts | 6 +- .../plugins/maps/server/mvt/get_grid_tile.ts | 7 +- x-pack/plugins/maps/server/mvt/mvt_routes.ts | 4 +- x-pack/plugins/maps/tsconfig.json | 1 + .../apis/maps/get_grid_tile.js | 135 +++++++++------ .../functional/apps/maps/mvt_geotile_grid.js | 2 +- 30 files changed, 739 insertions(+), 297 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts delete mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index fced15771c386..8ffd908770455 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -42,22 +42,24 @@ image::maps/images/grid_to_docs.gif[] [role="xpack"] [[maps-grid-aggregation]] -=== Grid aggregation +=== Clusters -Grid aggregation layers use {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] to group your documents into grids. You can calculate metrics for each gridded cell. +Clusters use {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] or {ref}/search-aggregations-bucket-geohexgrid-aggregation.html[Geohex grid aggregation] to group your documents into grids. You can calculate metrics for each gridded cell. -Symbolize grid aggregation metrics as: +Symbolize cluster metrics as: -*Clusters*:: Creates a <> with a cluster symbol for each gridded cell. +*Clusters*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> with a cluster symbol for each gridded cell. The cluster location is the weighted centroid for all documents in the gridded cell. -*Grid rectangles*:: Creates a <> with a bounding box polygon for each gridded cell. +*Grids*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> with a bounding box polygon for each gridded cell. -*Heat map*:: Creates a <> that clusters the weighted centroids for each gridded cell. +*Heat map*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> that clusters the weighted centroids for each gridded cell. -To enable a grid aggregation layer: +*Hexbins*:: Uses {ref}/search-aggregations-bucket-geohexgrid-aggregation.html[Geohex grid aggregation] to group your documents into H3 hexagon grids. Creates a <> with a hexagon polygon for each gridded cell. -. Click *Add layer*, then select the *Clusters and grids* or *Heat map* layer. +To enable a clusters layer: + +. Click *Add layer*, then select the *Clusters* or *Heat map* layer. To enable a blended layer that dynamically shows clusters or documents: diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index a85586fc43188..d4da7ef8aae2e 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -128,7 +128,7 @@ traffic. Larger circles will symbolize grids with more total bytes transferred, and smaller circles will symbolize grids with less bytes transferred. -. Click **Add layer**, and select **Clusters and grids**. +. Click **Add layer**, and select **Clusters**. . Set **Data view** to **kibana_sample_data_logs**. . Click **Add layer**. . In **Layer settings**, set: diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index cf6dd5334b07e..8ad2aaf4c9769 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -11,7 +11,7 @@ To add a vector layer to your map, click *Add layer*, then select one of the fol *Choropleth*:: Shaded areas to compare statistics across boundaries. -*Clusters and grids*:: Geospatial data grouped in grids with metrics for each gridded cell. +*Clusters*:: Geospatial data grouped in grids with metrics for each gridded cell. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. *Create index*:: Draw shapes on the map and index in Elasticsearch. diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 435b4e55b4cec..e02fead277f60 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -164,6 +164,7 @@ export enum RENDER_AS { HEATMAP = 'heatmap', POINT = 'point', GRID = 'grid', + HEX = 'hex', } export enum GRID_RESOLUTION { diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e049a0870855a..a3264a406b759 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -27,6 +27,7 @@ "presentationUtil" ], "optionalPlugins": [ + "cloud", "customIntegrations", "home", "savedObjectsTagging", diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx index 4c54d5faca5c7..abdc3a4f61fec 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx @@ -12,27 +12,24 @@ export const ClustersLayerIcon: FunctionComponent = () => ( xmlns="http://www.w3.org/2000/svg" width="49" height="25" - fill="none" viewBox="0 0 49 25" className="mapLayersWizardIcon" > - - - - - - - - - - - - - - - - - - + + + + ); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap index 90a5bd6758bde..7ef5e39ba96f0 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap @@ -1,6 +1,158 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`render 1`] = ` +exports[`should render 3 tick slider when renderAs is HEX 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is GRID 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is HEATMAP 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is POINT 1`] = `
- Clusters and grids + Clusters
{ @@ -48,62 +49,71 @@ export const clustersLayerWizardConfig: LayerWizard = { return; } + const sourceDescriptor = ESGeoGridSource.createDescriptor({ + ...sourceConfig, + resolution: GRID_RESOLUTION.FINE, + }); + const defaultDynamicProperties = getDefaultDynamicProperties(); - const layerDescriptor = GeoJsonVectorLayer.createDescriptor({ - sourceDescriptor: ESGeoGridSource.createDescriptor({ - ...sourceConfig, - resolution: GRID_RESOLUTION.FINE, - }), - style: VectorStyle.createDescriptor({ - // @ts-ignore - [VECTOR_STYLES.FILL_COLOR]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]! - .options as ColorDynamicOptions), - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - color: NUMERICAL_COLOR_PALETTES[0].value, - type: COLOR_MAP_TYPE.ORDINAL, + const style = VectorStyle.createDescriptor({ + // @ts-ignore + [VECTOR_STYLES.FILL_COLOR]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]!.options as ColorDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, + color: NUMERICAL_COLOR_PALETTES[0].value, + type: COLOR_MAP_TYPE.ORDINAL, }, - [VECTOR_STYLES.LINE_COLOR]: { - type: STYLE_TYPE.STATIC, - options: { - color: '#FFF', - }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#FFF', }, - [VECTOR_STYLES.LINE_WIDTH]: { - type: STYLE_TYPE.STATIC, - options: { - size: 0, - }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.STATIC, + options: { + size: 0, }, - [VECTOR_STYLES.ICON_SIZE]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options as SizeDynamicOptions), - maxSize: 24, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options as SizeDynamicOptions), + maxSize: 24, + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, }, - [VECTOR_STYLES.LABEL_TEXT]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, }, - }), + }, }); + + const layerDescriptor = + sourceDescriptor.requestType === RENDER_AS.HEX + ? MvtVectorLayer.createDescriptor({ + sourceDescriptor, + style, + }) + : GeoJsonVectorLayer.createDescriptor({ + sourceDescriptor, + style, + }); previewLayers([layerDescriptor]); }; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js index e829400c4bbef..872c7b71c9f92 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js @@ -51,10 +51,15 @@ export class CreateSourceEditor extends Component { ); }; - _onGeoFieldSelect = (geoField) => { + _onGeoFieldSelect = (geoFieldName) => { + const geoField = + this.state.indexPattern && geoFieldName + ? this.state.indexPattern.fields.getByName(geoFieldName) + : undefined; this.setState( { - geoField, + geoField: geoFieldName, + geoFieldType: geoField ? geoField.type : undefined, }, this.previewLayer ); @@ -85,7 +90,7 @@ export class CreateSourceEditor extends Component { return ( + ); } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index a26bd341613b2..cd93ccff99a4c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -316,7 +316,7 @@ describe('ESGeoGridSource', () => { const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234'); expect(tileUrl).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&requestType=point&token=1234" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 2eff5ce712ad5..67529edf15b4c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -45,13 +45,14 @@ import { DataView } from '../../../../../../../src/plugins/data/common'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { isValidStringConfig } from '../../util/valid_string_config'; import { makePublicExecutionContext } from '../../../util'; +import { isMvt } from './is_mvt'; type ESGeoGridSourceSyncMeta = Pick; const MAX_GEOTILE_LEVEL = 29; export const clustersTitle = i18n.translate('xpack.maps.source.esGridClustersTitle', { - defaultMessage: 'Clusters and grids', + defaultMessage: 'Clusters', }); export const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle', { @@ -87,6 +88,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo return ( { - if (this._descriptor.requestType === RENDER_AS.GRID) { + if ( + this._descriptor.requestType === RENDER_AS.GRID || + this._descriptor.requestType === RENDER_AS.HEX + ) { return [VECTOR_SHAPE_TYPE.POLYGON]; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts new file mode 100644 index 0000000000000..98115e9dcd992 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; + +export function isMvt(renderAs: RENDER_AS, resolution: GRID_RESOLUTION): boolean { + // heatmap uses MVT regardless of resolution because heatmap only supports counting metrics + if (renderAs === RENDER_AS.HEATMAP) { + return true; + } + + // hex uses MVT regardless of resolution because hex never supported "top terms" metric + if (renderAs === RENDER_AS.HEX) { + return true; + } + + // point and grid only use mvt at high resolution because lower resolutions may contain mvt unsupported "top terms" metric + return resolution === GRID_RESOLUTION.SUPER_FINE; +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx deleted file mode 100644 index 17fec469fe4ae..0000000000000 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { RENDER_AS } from '../../../../common/constants'; - -const options = [ - { - id: RENDER_AS.POINT, - label: i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { - defaultMessage: 'clusters', - }), - value: RENDER_AS.POINT, - }, - { - id: RENDER_AS.GRID, - label: i18n.translate('xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', { - defaultMessage: 'grids', - }), - value: RENDER_AS.GRID, - }, -]; - -export function RenderAsSelect(props: { - renderAs: RENDER_AS; - onChange: (newValue: RENDER_AS) => void; - isColumnCompressed?: boolean; -}) { - const currentOption = options.find((option) => option.value === props.renderAs) || options[0]; - - if (props.renderAs === RENDER_AS.HEATMAP) { - return null; - } - - function onChange(id: string) { - const data = options.find((option) => option.id === id); - if (data) { - props.onChange(data.value as RENDER_AS); - } - } - - return ( - - - - ); -} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts new file mode 100644 index 0000000000000..3e9d79d8cd486 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLUSTER_LABEL = i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { + defaultMessage: 'Clusters', +}); + +export const GRID_LABEL = i18n.translate( + 'xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', + { + defaultMessage: 'Grids', + } +); + +export const HEX_LABEL = i18n.translate('xpack.maps.source.esGeoGrid.hexDropdownOption', { + defaultMessage: 'Hexagons', +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts new file mode 100644 index 0000000000000..4930a8ebfc0a9 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { RenderAsSelect } from './render_as_select'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx new file mode 100644 index 0000000000000..e5baf65711d3f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ES_GEO_FIELD_TYPE, RENDER_AS } from '../../../../../common/constants'; +import { getIsCloud } from '../../../../kibana_services'; +import { getIsGoldPlus } from '../../../../licensed_features'; +import { CLUSTER_LABEL, GRID_LABEL, HEX_LABEL } from './i18n_constants'; +import { ShowAsLabel } from './show_as_label'; + +interface Props { + geoFieldType?: ES_GEO_FIELD_TYPE; + renderAs: RENDER_AS; + onChange: (newValue: RENDER_AS) => void; + isColumnCompressed?: boolean; +} + +export function RenderAsSelect(props: Props) { + if (props.renderAs === RENDER_AS.HEATMAP) { + return null; + } + + let isHexDisabled = false; + let hexDisabledReason = ''; + if (!getIsCloud() && !getIsGoldPlus()) { + isHexDisabled = true; + hexDisabledReason = i18n.translate('xpack.maps.hexbin.license.disabledReason', { + defaultMessage: '{hexLabel} is a subscription feature.', + values: { hexLabel: HEX_LABEL }, + }); + } else if (props.geoFieldType !== ES_GEO_FIELD_TYPE.GEO_POINT) { + isHexDisabled = true; + hexDisabledReason = i18n.translate('xpack.maps.hexbin.geoShape.disabledReason', { + defaultMessage: `{hexLabel} requires a 'geo_point' cluster field.`, + values: { hexLabel: HEX_LABEL }, + }); + } + + const options = [ + { + id: RENDER_AS.POINT, + label: CLUSTER_LABEL, + value: RENDER_AS.POINT, + }, + { + id: RENDER_AS.GRID, + label: GRID_LABEL, + value: RENDER_AS.GRID, + }, + { + id: RENDER_AS.HEX, + label: HEX_LABEL, + value: RENDER_AS.HEX, + isDisabled: isHexDisabled, + }, + ]; + + function onChange(id: string) { + const data = options.find((option) => option.id === id); + if (data) { + props.onChange(data.value as RENDER_AS); + } + } + + const currentOption = options.find((option) => option.value === props.renderAs) || options[0]; + + const selectLabel = ( + + ); + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx new file mode 100644 index 0000000000000..e16bc1cb8bcff --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CLUSTER_LABEL, GRID_LABEL, HEX_LABEL } from './i18n_constants'; + +interface Props { + isHexDisabled: boolean; + hexDisabledReason: string; +} + +export function ShowAsLabel(props: Props) { + return ( + +
+
{CLUSTER_LABEL}
+
+

+ +

+
+ +
{GRID_LABEL}
+
+

+ +

+
+ +
{HEX_LABEL}
+
+

+ +

+ {props.isHexDisabled ? {props.hexDisabledReason} : null} +
+
+ + } + > + + {' '} + + +
+ ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx index 9802b91b47cd6..bb659d13a2bb7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx @@ -9,16 +9,30 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ResolutionEditor } from './resolution_editor'; -import { GRID_RESOLUTION } from '../../../../common/constants'; +import { GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; const defaultProps = { - isHeatmap: false, resolution: GRID_RESOLUTION.COARSE, onChange: () => {}, metrics: [], }; -test('render', () => { - const component = shallow(); +test('should render 4 tick slider when renderAs is POINT', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 4 tick slider when renderAs is GRID', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 4 tick slider when renderAs is HEATMAP', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 3 tick slider when renderAs is HEX', () => { + const component = shallow(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx index 72dec66279164..d6f3758de1c0b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx @@ -10,46 +10,15 @@ import { EuiConfirmModal, EuiFormRow, EuiRange } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { AggDescriptor } from '../../../../common/descriptor_types'; -import { AGG_TYPE, GRID_RESOLUTION } from '../../../../common/constants'; - -function resolutionToSliderValue(resolution: GRID_RESOLUTION) { - if (resolution === GRID_RESOLUTION.SUPER_FINE) { - return 4; - } - - if (resolution === GRID_RESOLUTION.MOST_FINE) { - return 3; - } - - if (resolution === GRID_RESOLUTION.FINE) { - return 2; - } - - return 1; -} - -function sliderValueToResolution(value: number) { - if (value === 4) { - return GRID_RESOLUTION.SUPER_FINE; - } - - if (value === 3) { - return GRID_RESOLUTION.MOST_FINE; - } - - if (value === 2) { - return GRID_RESOLUTION.FINE; - } - - return GRID_RESOLUTION.COARSE; -} +import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; +import { isMvt } from './is_mvt'; function isUnsupportedVectorTileMetric(metric: AggDescriptor) { return metric.type === AGG_TYPE.TERMS; } interface Props { - isHeatmap: boolean; + renderAs: RENDER_AS; resolution: GRID_RESOLUTION; onChange: (resolution: GRID_RESOLUTION, metrics: AggDescriptor[]) => void; metrics: AggDescriptor[]; @@ -64,9 +33,70 @@ export class ResolutionEditor extends Component { showModal: false, }; + _getScale() { + return this.props.renderAs === RENDER_AS.HEX + ? { + [GRID_RESOLUTION.SUPER_FINE]: 3, + [GRID_RESOLUTION.MOST_FINE]: 2, + [GRID_RESOLUTION.FINE]: 2, + [GRID_RESOLUTION.COARSE]: 1, + } + : { + [GRID_RESOLUTION.SUPER_FINE]: 4, + [GRID_RESOLUTION.MOST_FINE]: 3, + [GRID_RESOLUTION.FINE]: 2, + [GRID_RESOLUTION.COARSE]: 1, + }; + } + + _getTicks() { + const scale = this._getScale(); + const unlabeledTicks = [ + { + label: '', + value: scale[GRID_RESOLUTION.FINE], + }, + ]; + if (scale[GRID_RESOLUTION.FINE] !== scale[GRID_RESOLUTION.MOST_FINE]) { + unlabeledTicks.push({ + label: '', + value: scale[GRID_RESOLUTION.MOST_FINE], + }); + } + + return [ + { + label: i18n.translate('xpack.maps.source.esGrid.lowLabel', { + defaultMessage: `low`, + }), + value: scale[GRID_RESOLUTION.COARSE], + }, + ...unlabeledTicks, + { + label: i18n.translate('xpack.maps.source.esGrid.highLabel', { + defaultMessage: `high`, + }), + value: scale[GRID_RESOLUTION.SUPER_FINE], + }, + ]; + } + + _resolutionToSliderValue(resolution: GRID_RESOLUTION): number { + const scale = this._getScale(); + return scale[resolution]; + } + + _sliderValueToResolution(value: number): GRID_RESOLUTION { + const scale = this._getScale(); + const resolution = Object.keys(scale).find((key) => { + return scale[key as GRID_RESOLUTION] === value; + }); + return resolution ? (resolution as GRID_RESOLUTION) : GRID_RESOLUTION.COARSE; + } + _onResolutionChange = (event: ChangeEvent | MouseEvent) => { - const resolution = sliderValueToResolution(parseInt(event.currentTarget.value, 10)); - if (!this.props.isHeatmap && resolution === GRID_RESOLUTION.SUPER_FINE) { + const resolution = this._sliderValueToResolution(parseInt(event.currentTarget.value, 10)); + if (isMvt(this.props.renderAs, resolution)) { const hasUnsupportedMetrics = this.props.metrics.find(isUnsupportedVectorTileMetric); if (hasUnsupportedMetrics) { this.setState({ showModal: true }); @@ -129,11 +159,13 @@ export class ResolutionEditor extends Component { render() { const helpText = - !this.props.isHeatmap && this.props.resolution === GRID_RESOLUTION.SUPER_FINE + (this.props.renderAs === RENDER_AS.POINT || this.props.renderAs === RENDER_AS.GRID) && + this.props.resolution === GRID_RESOLUTION.SUPER_FINE ? i18n.translate('xpack.maps.source.esGrid.superFineHelpText', { defaultMessage: 'High resolution uses vector tiles.', }) : undefined; + const ticks = this._getTicks(); return ( <> {this._renderModal()} @@ -145,28 +177,13 @@ export class ResolutionEditor extends Component { display="columnCompressed" >
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx index 3ddb804cac213..0df4c492940b7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx @@ -19,6 +19,7 @@ jest.mock('uuid/v4', () => { const defaultProps = { currentLayerType: LAYER_TYPE.GEOJSON_VECTOR, + geoFieldName: 'myLocation', indexPatternId: 'foobar', onChange: async () => {}, metrics: [], diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx index 4754d26702c9c..1ef695e9dcfac 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx @@ -11,7 +11,13 @@ import uuid from 'uuid/v4'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPanel, EuiSpacer, EuiComboBoxOptionOption, EuiTitle } from '@elastic/eui'; import { getDataViewNotFoundMessage } from '../../../../common/i18n_getters'; -import { AGG_TYPE, GRID_RESOLUTION, LAYER_TYPE, RENDER_AS } from '../../../../common/constants'; +import { + AGG_TYPE, + ES_GEO_FIELD_TYPE, + GRID_RESOLUTION, + LAYER_TYPE, + RENDER_AS, +} from '../../../../common/constants'; import { MetricsEditor } from '../../../components/metrics_editor'; import { getIndexPatternService } from '../../../kibana_services'; import { ResolutionEditor } from './resolution_editor'; @@ -21,9 +27,11 @@ import { RenderAsSelect } from './render_as_select'; import { AggDescriptor } from '../../../../common/descriptor_types'; import { OnSourceChangeArgs } from '../source'; import { clustersTitle, heatmapTitle } from './es_geo_grid_source'; +import { isMvt } from './is_mvt'; interface Props { currentLayerType?: string; + geoFieldName: string; indexPatternId: string; onChange: (...args: OnSourceChangeArgs[]) => Promise; metrics: AggDescriptor[]; @@ -32,6 +40,7 @@ interface Props { } interface State { + geoFieldType?: ES_GEO_FIELD_TYPE; metricsEditorKey: string; fields: IndexPatternField[]; loadError?: string; @@ -70,30 +79,42 @@ export class UpdateSourceEditor extends Component { return; } + const geoField = indexPattern.fields.getByName(this.props.geoFieldName); + this.setState({ fields: indexPattern.fields.filter((field) => !indexPatterns.isNestedField(field)), + geoFieldType: geoField ? (geoField.type as ES_GEO_FIELD_TYPE) : undefined, }); } + _getNewLayerType(renderAs: RENDER_AS, resolution: GRID_RESOLUTION): LAYER_TYPE | undefined { + let nextLayerType: LAYER_TYPE | undefined; + if (renderAs === RENDER_AS.HEATMAP) { + nextLayerType = LAYER_TYPE.HEATMAP; + } else if (isMvt(renderAs, resolution)) { + nextLayerType = LAYER_TYPE.MVT_VECTOR; + } else { + nextLayerType = LAYER_TYPE.GEOJSON_VECTOR; + } + + // only return newLayerType if there is a change from current layer type + return nextLayerType !== undefined && nextLayerType !== this.props.currentLayerType + ? nextLayerType + : undefined; + } + _onMetricsChange = (metrics: AggDescriptor[]) => { this.props.onChange({ propName: 'metrics', value: metrics }); }; _onResolutionChange = async (resolution: GRID_RESOLUTION, metrics: AggDescriptor[]) => { - let newLayerType; - if ( - this.props.currentLayerType === LAYER_TYPE.GEOJSON_VECTOR || - this.props.currentLayerType === LAYER_TYPE.MVT_VECTOR - ) { - newLayerType = - resolution === GRID_RESOLUTION.SUPER_FINE - ? LAYER_TYPE.MVT_VECTOR - : LAYER_TYPE.GEOJSON_VECTOR; - } - await this.props.onChange( { propName: 'metrics', value: metrics }, - { propName: 'resolution', value: resolution, newLayerType } + { + propName: 'resolution', + value: resolution, + newLayerType: this._getNewLayerType(this.props.renderAs, resolution), + } ); // Metrics editor persists metrics in state. @@ -102,7 +123,11 @@ export class UpdateSourceEditor extends Component { }; _onRequestTypeSelect = (requestType: RENDER_AS) => { - this.props.onChange({ propName: 'requestType', value: requestType }); + this.props.onChange({ + propName: 'requestType', + value: requestType, + newLayerType: this._getNewLayerType(requestType, this.props.resolution), + }); }; _getMetricsFilter() { @@ -155,13 +180,14 @@ export class UpdateSourceEditor extends Component { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index e1090a16ec665..27c11d27673f2 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -341,7 +341,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource getGeoFieldName(): string { if (!this._descriptor.geoField) { - throw new Error('Should not call'); + throw new Error(`Required field 'geoField' not provided in '_descriptor'`); } return this._descriptor.geoField; } diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index d8197902c73ac..88338dd508eec 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -23,6 +23,12 @@ export function setStartServices(core: CoreStart, plugins: MapsPluginStartDepend emsSettings = mapsEms.createEMSSettings(); } +let isCloudEnabled = false; +export function setIsCloudEnabled(enabled: boolean) { + isCloudEnabled = enabled; +} +export const getIsCloud = () => isCloudEnabled; + export const getIndexNameFormComponent = () => pluginsStart.fileUpload.IndexNameFormComponent; export const getFileUploadComponent = () => pluginsStart.fileUpload.FileUploadComponent; export const getIndexPatternService = () => pluginsStart.data.indexPatterns; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 21c33cdcb500a..bef5cd7039f7e 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -22,7 +22,7 @@ import type { } from '../../../../src/core/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { MapInspectorView } from './inspector/map_inspector_view'; -import { setMapAppConfig, setStartServices } from './kibana_services'; +import { setIsCloudEnabled, setMapAppConfig, setStartServices } from './kibana_services'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { getMapsVisTypeAlias } from './maps_vis_type_alias'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; @@ -74,11 +74,13 @@ import { } from './legacy_visualizations'; import type { SecurityPluginStart } from '../../security/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import type { CloudSetup } from '../../cloud/public'; import type { LensPublicSetup } from '../../lens/public'; import { setupLensChoroplethChart } from './lens'; export interface MapsPluginSetupDependencies { + cloud?: CloudSetup; expressions: ReturnType; inspector: InspectorSetupContract; home?: HomePublicPluginSetup; @@ -193,6 +195,8 @@ export class MapsPlugin plugins.expressions.registerRenderer(tileMapRenderer); plugins.visualizations.createBaseVisualization(tileMapVisType); + setIsCloudEnabled(!!plugins.cloud?.isCloudEnabled); + return { registerLayerWizard: registerLayerWizardExternal, registerSource, diff --git a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts index 28effa5eabfba..754fdb9c1f4d2 100644 --- a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts @@ -23,7 +23,7 @@ export async function getEsGridTile({ y, z, requestBody = {}, - requestType = RENDER_AS.POINT, + renderAs = RENDER_AS.POINT, gridPrecision, abortController, }: { @@ -37,7 +37,7 @@ export async function getEsGridTile({ context: DataRequestHandlerContext; logger: Logger; requestBody: any; - requestType: RENDER_AS.GRID | RENDER_AS.POINT; + renderAs: RENDER_AS; gridPrecision: number; abortController: AbortController; }): Promise { @@ -49,7 +49,8 @@ export async function getEsGridTile({ exact_bounds: false, extent: 4096, // full resolution, query: requestBody.query, - grid_type: requestType === RENDER_AS.GRID ? 'grid' : 'centroid', + grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile', + grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid', aggs: requestBody.aggs, fields: requestBody.fields, runtime_mappings: requestBody.runtime_mappings, diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 5fdaea9ab66df..dde68bd0d1335 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -88,7 +88,7 @@ export function initMVTRoutes({ geometryFieldName: schema.string(), requestBody: schema.string(), index: schema.string(), - requestType: schema.string(), + renderAs: schema.string(), token: schema.maybe(schema.string()), gridPrecision: schema.number(), }), @@ -114,7 +114,7 @@ export function initMVTRoutes({ z: parseInt((params as any).z, 10) as number, index: query.index as string, requestBody: decodeMvtResponseBody(query.requestBody as string) as any, - requestType: query.requestType as RENDER_AS.POINT | RENDER_AS.GRID, + renderAs: query.renderAs as RENDER_AS, gridPrecision: parseInt(query.gridPrecision, 10), abortController, }); diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index ed188c609c330..fbbc9cae2e3c9 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -32,6 +32,7 @@ { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index a1b420755a31a..ab8c86215a3a5 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -13,16 +13,15 @@ export default function ({ getService }) { const supertest = getService('supertest'); describe('getGridTile', () => { - it('should return vector tile containing cluster features', async () => { - const resp = await supertest - .get( - `/api/maps/mvt/getGridTile/3/2/3.pbf\ + const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ &index=logstash-*\ &gridPrecision=8\ -&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))\ -&requestType=point` - ) +&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))`; + + it('should return vector tile with expected headers', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') .set('kbn-xsrf', 'kibana') .responseType('blob') .expect(200); @@ -31,6 +30,14 @@ export default function ({ getService }) { expect(resp.headers['content-disposition']).to.be('inline'); expect(resp.headers['content-type']).to.be('application/x-protobuf'); expect(resp.headers['cache-control']).to.be('public, max-age=3600'); + }); + + it('should return vector tile containing clusters when renderAs is "point"', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); const jsonTile = new VectorTile(new Protobuf(resp.body)); @@ -46,59 +53,44 @@ export default function ({ getService }) { _key: '11/517/809', 'avg_of_bytes.value': 9252, }); - expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); - // Metadata feature - const metaDataLayer = jsonTile.layers.meta; - expect(metaDataLayer.length).to.be(1); - const metadataFeature = metaDataLayer.feature(0); - expect(metadataFeature.type).to.be(3); - expect(metadataFeature.extent).to.be(4096); + // assert feature geometry is weighted centroid + expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); + }); - expect(metadataFeature.properties['aggregations._count.avg']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.count']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.min']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.sum']).to.eql(1); + it('should return vector tile containing clusters with renderAs is "heatmap"', async () => { + const resp = await supertest + .get(URL + '&renderAs=heatmap') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); - expect(metadataFeature.properties['aggregations.avg_of_bytes.avg']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.count']).to.eql(1); - expect(metadataFeature.properties['aggregations.avg_of_bytes.max']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.min']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.sum']).to.eql(9252); + const jsonTile = new VectorTile(new Protobuf(resp.body)); - expect(metadataFeature.properties['hits.total.relation']).to.eql('eq'); - expect(metadataFeature.properties['hits.total.value']).to.eql(1); + // Cluster feature + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(1); + const clusterFeature = layer.feature(0); + expect(clusterFeature.type).to.be(1); + expect(clusterFeature.extent).to.be(4096); + expect(clusterFeature.id).to.be(undefined); + expect(clusterFeature.properties).to.eql({ + _count: 1, + _key: '11/517/809', + 'avg_of_bytes.value': 9252, + }); - expect(metadataFeature.loadGeometry()).to.eql([ - [ - { x: 0, y: 4096 }, - { x: 4096, y: 4096 }, - { x: 4096, y: 0 }, - { x: 0, y: 0 }, - { x: 0, y: 4096 }, - ], - ]); + // assert feature geometry is weighted centroid + expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); }); - it('should return vector tile containing grid features', async () => { + it('should return vector tile containing grid features when renderAs is "grid"', async () => { const resp = await supertest - .get( - `/api/maps/mvt/getGridTile/3/2/3.pbf\ -?geometryFieldName=geo.coordinates\ -&index=logstash-*\ -&gridPrecision=8\ -&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))\ -&requestType=grid` - ) + .get(URL + '&renderAs=grid') .set('kbn-xsrf', 'kibana') .responseType('blob') .expect(200); - expect(resp.headers['content-encoding']).to.be('gzip'); - expect(resp.headers['content-disposition']).to.be('inline'); - expect(resp.headers['content-type']).to.be('application/x-protobuf'); - expect(resp.headers['cache-control']).to.be('public, max-age=3600'); - const jsonTile = new VectorTile(new Protobuf(resp.body)); const layer = jsonTile.layers.aggs; expect(layer.length).to.be(1); @@ -112,6 +104,8 @@ export default function ({ getService }) { _key: '11/517/809', 'avg_of_bytes.value': 9252, }); + + // assert feature geometry is grid expect(gridFeature.loadGeometry()).to.eql([ [ { x: 80, y: 672 }, @@ -121,6 +115,51 @@ export default function ({ getService }) { { x: 80, y: 672 }, ], ]); + }); + + it('should return vector tile containing hexegon features when renderAs is "hex"', async () => { + const resp = await supertest + .get(URL + '&renderAs=hex') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(1); + + const gridFeature = layer.feature(0); + expect(gridFeature.type).to.be(3); + expect(gridFeature.extent).to.be(4096); + expect(gridFeature.id).to.be(undefined); + expect(gridFeature.properties).to.eql({ + _count: 1, + _key: '85264a33fffffff', + 'avg_of_bytes.value': 9252, + }); + + // assert feature geometry is hex + expect(gridFeature.loadGeometry()).to.eql([ + [ + { x: 102, y: 669 }, + { x: 99, y: 659 }, + { x: 89, y: 657 }, + { x: 83, y: 664 }, + { x: 86, y: 674 }, + { x: 96, y: 676 }, + { x: 102, y: 669 }, + ], + ]); + }); + + it('should return vector tile with meta layer', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); // Metadata feature const metaDataLayer = jsonTile.layers.meta; diff --git a/x-pack/test/functional/apps/maps/mvt_geotile_grid.js b/x-pack/test/functional/apps/maps/mvt_geotile_grid.js index d56b389b878f3..40dfa5ac8e571 100644 --- a/x-pack/test/functional/apps/maps/mvt_geotile_grid.js +++ b/x-pack/test/functional/apps/maps/mvt_geotile_grid.js @@ -47,7 +47,7 @@ export default function ({ getPageObjects, getService }) { geometryFieldName: 'geo.coordinates', index: 'logstash-*', gridPrecision: 8, - requestType: 'grid', + renderAs: 'grid', requestBody: `(_source:(excludes:!()),aggs:(max_of_bytes:(max:(field:bytes))),fields:!((field:'@timestamp',format:date_time),(field:'relatedContent.article:modified_time',format:date_time),(field:'relatedContent.article:published_time',format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((range:('@timestamp':(format:strict_date_optional_time,gte:'2015-09-20T00:00:00.000Z',lte:'2015-09-20T01:00:00.000Z')))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:'doc[!'@timestamp!'].value.getHour()'))),size:0,stored_fields:!('*'))`, }); From d7e17d78ebeb653bd4b5cda59df05f370939ab04 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Mar 2022 08:20:45 -0600 Subject: [PATCH 11/66] [lens] include number of values in default terms field label (#127222) * [lens] include number of values in default terms field label * remove size from rare values * revert changes to label with secondary terms * fix jest tests * i18n cleanup, fix lens smokescreen functional test * functional test expects * funcational test expect * handle single value * eslint * revert changes to expect * update functional test expect * functional test expect update * test expects * expects * expects * expects * expects * expects Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/management/_scripted_fields.ts | 4 +- .../droppable/droppable.test.ts | 10 ++-- .../operations/definitions/terms/index.tsx | 59 ++++++++++++++----- .../definitions/terms/terms.test.tsx | 19 +++--- .../operations/layer_helpers.test.ts | 6 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../functional/apps/lens/drag_and_drop.ts | 26 ++++---- .../functional/apps/lens/runtime_fields.ts | 4 +- .../test/functional/apps/lens/smokescreen.ts | 2 +- 11 files changed, 80 insertions(+), 53 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields.ts b/test/functional/apps/management/_scripted_fields.ts index c8c605ec7ed19..a6bbe798cf56b 100644 --- a/test/functional/apps/management/_scripted_fields.ts +++ b/test/functional/apps/management/_scripted_fields.ts @@ -276,7 +276,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // verify Lens opens a visualization expect(await testSubjects.getVisibleTextAll('lns-dimensionTrigger')).to.contain( - 'Top values of painString' + 'Top 5 values of painString' ); }); }); @@ -363,7 +363,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // verify Lens opens a visualization expect(await testSubjects.getVisibleTextAll('lns-dimensionTrigger')).to.contain( - 'Top values of painBool' + 'Top 5 values of painBool' ); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index ea3978ce8ca94..778b589d283e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -144,7 +144,7 @@ const multipleColumnsLayer: IndexPatternLayer = { columns: { col1: oneColumnLayer.columns.col1, col2: { - label: 'Top values of src', + label: 'Top 10 values of src', dataType: 'string', isBucketed: true, // Private @@ -157,7 +157,7 @@ const multipleColumnsLayer: IndexPatternLayer = { sourceField: 'src', } as TermsIndexPatternColumn, col3: { - label: 'Top values of dest', + label: 'Top 10 values of dest', dataType: 'string', isBucketed: true, @@ -1620,7 +1620,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { - label: 'Top values of src', + label: 'Top 10 values of src', dataType: 'string', isBucketed: true, @@ -2192,7 +2192,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { isBucketed: true, - label: 'Top values of bytes', + label: 'Top 10 values of bytes', operationType: 'terms', sourceField: 'bytes', dataType: 'number', @@ -2284,7 +2284,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { isBucketed: true, - label: 'Top values of bytes', + label: 'Top 10 values of bytes', operationType: 'terms', sourceField: 'bytes', dataType: 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index fbb990e1dab81..c881bc898e8ad 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -60,7 +60,12 @@ const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLa defaultMessage: 'Missing field', }); -function ofName(name?: string, count: number = 0, rare: boolean = false) { +function ofName( + name?: string, + secondaryFieldsCount: number = 0, + rare: boolean = false, + termsSize: number = 0 +) { if (rare) { return i18n.translate('xpack.lens.indexPattern.rareTermsOf', { defaultMessage: 'Rare values of {name}', @@ -69,19 +74,22 @@ function ofName(name?: string, count: number = 0, rare: boolean = false) { }, }); } - if (count) { + if (secondaryFieldsCount) { return i18n.translate('xpack.lens.indexPattern.multipleTermsOf', { defaultMessage: 'Top values of {name} + {count} {count, plural, one {other} other {others}}', values: { name: name ?? missingFieldLabel, - count, + count: secondaryFieldsCount, }, }); } return i18n.translate('xpack.lens.indexPattern.termsOf', { - defaultMessage: 'Top values of {name}', + defaultMessage: + 'Top {numberOfTermsLabel}{termsCount, plural, one {value} other {values}} of {name}', values: { name: name ?? missingFieldLabel, + termsCount: termsSize, + numberOfTermsLabel: termsSize > 1 ? `${termsSize} ` : '', }, }); } @@ -270,7 +278,8 @@ export const termsOperation: OperationDefinition { const newParams = { @@ -285,6 +294,7 @@ export const termsOperation: OperationDefinition { - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'size', - value, - }) - ); + updateLayer({ + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...currentColumn, + label: currentColumn.customLabel + ? currentColumn.label + : ofName( + indexPattern.getFieldByName(currentColumn.sourceField)?.displayName, + secondaryFieldsCount, + currentColumn.params.orderBy.type === 'rare', + value + ), + params: { + ...currentColumn.params, + size: value, + }, + }, + } as Record, + }); }} /> {currentColumn.params.orderBy.type === 'rare' && ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 0da1f0977e4bc..a72250c2265c4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -79,7 +79,7 @@ describe('terms', () => { columnOrder: ['col1', 'col2'], columns: { col1: { - label: 'Top values of source', + label: 'Top 3 values of source', dataType: 'string', isBucketed: true, operationType: 'terms', @@ -199,7 +199,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'source', - label: 'Top values of source', + label: 'Top 5 values of source', isBucketed: true, dataType: 'string', params: { @@ -226,7 +226,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -254,7 +254,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -278,7 +278,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -304,7 +304,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -327,7 +327,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -929,7 +929,7 @@ describe('terms', () => { createMockedIndexPattern(), {} ) - ).toBe('Top values of source'); + ).toBe('Top 3 values of source'); }); it('should return main value with single counter for two fields', () => { @@ -2083,6 +2083,7 @@ describe('terms', () => { ...layer.columns, col1: { ...layer.columns.col1, + label: 'Top 7 values of source', params: { ...(layer.columns.col1 as TermsIndexPatternColumn).params, size: 7, @@ -2101,7 +2102,7 @@ describe('terms', () => { col1: { dataType: 'boolean', isBucketed: true, - label: 'Top values of bytes', + label: 'Top 5 values of bytes', operationType: 'terms', params: { missingBucket: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index b6398970056e2..15cfcda26d917 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -767,7 +767,7 @@ describe('state_helpers', () => { }).columns.col2 ).toEqual( expect.objectContaining({ - label: 'Top values of bytes', + label: 'Top 3 values of bytes', }) ); }); @@ -1079,7 +1079,7 @@ describe('state_helpers', () => { }).columns.col1 ).toEqual( expect.objectContaining({ - label: 'Top values of source', + label: 'Top 3 values of source', }) ); }); @@ -2251,7 +2251,7 @@ describe('state_helpers', () => { it('should remove column and any incomplete state', () => { const termsColumn: TermsIndexPatternColumn = { - label: 'Top values of source', + label: 'Top 5 values of source', dataType: 'string', isBucketed: true, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ac95693301e54..8914efcf12ded 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -553,7 +553,6 @@ "xpack.lens.indexPattern.terms.otherBucketDescription": "Regrouper les autres valeurs sous \"Autre\"", "xpack.lens.indexPattern.terms.otherLabel": "Autre", "xpack.lens.indexPattern.terms.size": "Nombre de valeurs", - "xpack.lens.indexPattern.termsOf": "Valeurs les plus élevées de {name}", "xpack.lens.indexPattern.termsWithMultipleShifts": "Dans un seul calque, il est impossible de combiner des indicateurs avec des décalages temporels différents et des valeurs dynamiques les plus élevées. Utilisez la même valeur de décalage pour tous les indicateurs, ou utilisez des filtres à la place des valeurs les plus élevées.", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "Utiliser des filtres", "xpack.lens.indexPattern.timeScale.enableTimeScale": "Normaliser par unité", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2337f25502da3..48f0d74d73765 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -654,7 +654,6 @@ "xpack.lens.indexPattern.terms.size": "値の数", "xpack.lens.indexPattern.terms.sizeLimitMax": "値が最大値{max}を超えています。最大値が使用されます。", "xpack.lens.indexPattern.terms.sizeLimitMin": "値が最小値{min}未満です。最小値が使用されます。", - "xpack.lens.indexPattern.termsOf": "{name} のトップの値", "xpack.lens.indexPattern.termsWithMultipleShifts": "単一のレイヤーでは、メトリックを異なる時間シフトと動的な上位の値と組み合わせることができません。すべてのメトリックで同じ時間シフト値を使用するか、上位の値ではなくフィルターを使用します。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "フィルターを使用", "xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields": "複数のフィールドを使用するときには、スクリプトフィールドがサポートされていません。{fields}が見つかりました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 158534694943a..bbc00d8d205f7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -660,7 +660,6 @@ "xpack.lens.indexPattern.terms.size": "值数目", "xpack.lens.indexPattern.terms.sizeLimitMax": "值大于最大值 {max},将改为使用最大值。", "xpack.lens.indexPattern.terms.sizeLimitMin": "值小于最小值 {min},将改为使用最小值。", - "xpack.lens.indexPattern.termsOf": "{name} 排名最前值", "xpack.lens.indexPattern.termsWithMultipleShifts": "在单个图层中,无法将指标与不同时间偏移和动态排名最前值组合。将相同的时间偏移值用于所有指标或使用筛选,而非排名最前值。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "使用筛选", "xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields": "使用多个字段时不支持脚本字段,找到 {fields}", diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index 27e336a1cbc12..1a7b8e96d6802 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -33,7 +33,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'lnsDatatable_rows > lns-dimensionTrigger' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_rows')).to.eql( - 'Top values of clientip' + 'Top 3 values of clientip' ); await PageObjects.lens.dragFieldToDimensionTrigger( @@ -48,7 +48,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'lnsDatatable_rows > lns-empty-dimension' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_rows', 2)).to.eql( - 'Top values of @message.raw' + 'Top 3 values of @message.raw' ); }); @@ -56,8 +56,8 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.reorderDimensions('lnsDatatable_rows', 3, 1); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsDatatable_rows')).to.eql([ - 'Top values of @message.raw', - 'Top values of clientip', + 'Top 3 values of @message.raw', + 'Top 3 values of clientip', 'bytes', ]); }); @@ -65,11 +65,11 @@ export default function ({ getPageObjects }: FtrProviderContext) { it('should move the column to compatible dimension group', async () => { await PageObjects.lens.switchToVisualization('bar'); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ - 'Top values of @message.raw', + 'Top 3 values of @message.raw', ]); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.dragDimensionToDimension( 'lnsXY_xDimensionPanel > lns-dimensionTrigger', @@ -81,13 +81,13 @@ export default function ({ getPageObjects }: FtrProviderContext) { ); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); }); it('should move the column to non-compatible dimension group', async () => { expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); await PageObjects.lens.dragDimensionToDimension( 'lnsXY_splitDimensionPanel > lns-dimensionTrigger', @@ -129,7 +129,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Unique count of @message.raw [1]', ]); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ - 'Top values of @message.raw', + 'Top 5 values of @message.raw', ]); }); @@ -159,7 +159,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Unique count of @timestamp' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_splitDimensionPanel')).to.eql( - 'Top values of @message.raw' + 'Top 3 values of @message.raw' ); }); @@ -244,14 +244,14 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); await PageObjects.lens.assertFocusedField('@message.raw'); }); it('should drop a field to an existing dimension replacing the old one', async () => { await PageObjects.lens.dragFieldWithKeyboard('clientip', 1, true); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.assertFocusedField('clientip'); }); @@ -319,7 +319,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization(); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.openDimensionEditor( 'lnsXY_splitDimensionPanel > lns-dimensionTrigger' ); diff --git a/x-pack/test/functional/apps/lens/runtime_fields.ts b/x-pack/test/functional/apps/lens/runtime_fields.ts index 1353bcaea2c84..252951cba4bd0 100644 --- a/x-pack/test/functional/apps/lens/runtime_fields.ts +++ b/x-pack/test/functional/apps/lens/runtime_fields.ts @@ -33,7 +33,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( - 'Top values of runtimefield' + 'Top 5 values of runtimefield' ); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); }); @@ -62,7 +62,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( - 'Top values of runtimefield2' + 'Top 5 values of runtimefield2' ); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); }); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 4887f96c6870a..f0cc3b0da7201 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -337,7 +337,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getLayerCount()).to.eql(1); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql( - 'Top values of geo.dest' + 'Top 5 values of geo.dest' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sizeByDimensionPanel')).to.eql( 'Average of bytes' From b028cf97ed9a554fef723be1f38ffabbfc41bac1 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 23 Mar 2022 10:28:43 -0400 Subject: [PATCH 12/66] [ResponseOps][task manager] log event loop delay for tasks when over configured limit (#126300) resolves https://github.com/elastic/kibana/issues/124366 Adds new task manager configuration keys. - `xpack.task_manager.event_loop_delay.monitor` - whether to monitor event loop delay or not; added in case this specific monitoring causes other issues and we'd want to disable it. We don't know of any cases where we'd need this today - `xpack.task_manager.event_loop_delay.warn_threshold` - the number of milliseconds of event loop delay before logging a warning This code uses the `perf_hooks.monitorEventLoopDelay()` API[1] to collect the event loop delay while a task is running. [1] https://nodejs.org/api/perf_hooks.html#perf_hooksmonitoreventloopdelayoptions When a significant event loop delay is encountered, it's very likely that other tasks running at the same time will be affected, and so will also end up having a long event loop delay value, and warnings will be logged on those. Over time, though, tasks which have consistently long event loop delays will outnumber those unfortunate peer tasks, and be obvious from the volume in the logs. To make it a bit easier to find these when viewing Kibana logs in Discover, tags are added to the logged messages to make it easier to find them. One tag is `event-loop-blocked`, second is the task type, and the third is a string consisting of the task type and task id. --- docs/settings/task-manager-settings.asciidoc | 5 ++ .../resources/base/bin/kibana-docker | 2 + .../task_manager/server/config.test.ts | 12 +++ x-pack/plugins/task_manager/server/config.ts | 10 +++ .../server/ephemeral_task_lifecycle.test.ts | 4 + .../managed_configuration.test.ts | 4 + .../configuration_statistics.test.ts | 4 + .../monitoring_stats_stream.test.ts | 4 + .../task_manager/server/plugin.test.ts | 12 +++ .../server/polling_lifecycle.test.ts | 4 + .../task_manager/server/polling_lifecycle.ts | 3 + .../task_manager/server/task_events.test.ts | 82 +++++++++++++++++++ .../task_manager/server/task_events.ts | 20 +++++ .../server/task_running/task_runner.test.ts | 10 ++- .../server/task_running/task_runner.ts | 21 ++++- 15 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/task_events.test.ts diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index b7423d7c37b31..5f31c9adc879d 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -40,6 +40,11 @@ These non-persisted action tasks have a risk that they won't be run at all if th `xpack.task_manager.ephemeral_tasks.request_capacity`:: Sets the size of the ephemeral queue defined above. Defaults to 10. +`xpack.task_manager.event_loop_delay.monitor`:: +Enables event loop delay monitoring, which will log a warning when a task causes an event loop delay which exceeds the `warn_threshold` setting. Defaults to true. + +`xpack.task_manager.event_loop_delay.warn_threshold`:: +Sets the amount of event loop delay during a task execution which will cause a warning to be logged. Defaults to 5000 milliseconds (5 seconds). [float] [[task-manager-health-settings]] diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 01d27a345378b..83a542c93d12b 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -379,6 +379,8 @@ kibana_vars=( xpack.task_manager.poll_interval xpack.task_manager.request_capacity xpack.task_manager.version_conflict_threshold + xpack.task_manager.event_loop_delay.monitor + xpack.task_manager.event_loop_delay.warn_threshold xpack.uptime.index ) diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 4c4db2aba7128..f5ba0a3bcee0a 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -16,6 +16,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -62,6 +66,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -106,6 +114,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index 5a58e45a70d96..f650ed093cee0 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -41,6 +41,14 @@ export const taskExecutionFailureThresholdSchema = schema.object( } ); +const eventLoopDelaySchema = schema.object({ + monitor: schema.boolean({ defaultValue: true }), + warn_threshold: schema.number({ + defaultValue: 5000, + min: 10, + }), +}); + export const configSchema = schema.object( { /* The maximum number of times a task will be attempted before being abandoned as failed */ @@ -118,6 +126,7 @@ export const configSchema = schema.object( max: DEFAULT_MAX_EPHEMERAL_REQUEST_CAPACITY, }), }), + event_loop_delay: eventLoopDelaySchema, /* These are not designed to be used by most users. Please use caution when changing these */ unsafe: schema.object({ exclude_task_types: schema.arrayOf(schema.string(), { defaultValue: [] }), @@ -138,3 +147,4 @@ export const configSchema = schema.object( export type TaskManagerConfig = TypeOf; export type TaskExecutionFailureThreshold = TypeOf; +export type EventLoopDelayConfig = TypeOf; diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts index 639bb834eeb4c..1d98e37a06a55 100644 --- a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -69,6 +69,10 @@ describe('EphemeralTaskLifecycle', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, ...config, }, elasticsearchAndSOAvailability$, diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index 3442e69aab44a..c5f03b1769385 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -57,6 +57,10 @@ describe.skip('managed configuration', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); logger = context.logger.get('taskManager'); diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 77fd9a8f11fab..776f5bc9388f7 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -40,6 +40,10 @@ describe('Configuration Statistics Aggregator', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }; const managedConfig = { diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 8aa2d54d89623..a6ef665966ddd 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -44,6 +44,10 @@ describe('createMonitoringStatsStream', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }; it('returns the initial config used to configure Task Manager', async () => { diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 20e5f211a5b4e..aa91533eabadf 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -43,6 +43,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); pluginInitializerContext.env.instanceUuid = ''; @@ -84,6 +88,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); const taskManagerPlugin = new TaskManagerPlugin(pluginInitializerContext); @@ -154,6 +162,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: ['*'], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); const logger = pluginInitializerContext.logger.get(); diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index cf29d1f475c6c..7cbaa5a165544 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -67,6 +67,10 @@ describe('TaskPollingLifecycle', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }, taskStore: mockTaskStore, logger: taskManagerLogger, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index a452c8a3f82fb..ee7e2ec32932e 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -91,6 +91,7 @@ export class TaskPollingLifecycle { private middleware: Middleware; private usageCounter?: UsageCounter; + private config: TaskManagerConfig; /** * Initializes the task manager, preventing any further addition of middleware, @@ -117,6 +118,7 @@ export class TaskPollingLifecycle { this.store = taskStore; this.executionContext = executionContext; this.usageCounter = usageCounter; + this.config = config; const emitEvent = (event: TaskLifecycleEvent) => this.events$.next(event); @@ -240,6 +242,7 @@ export class TaskPollingLifecycle { defaultMaxAttempts: this.taskClaiming.maxAttempts, executionContext: this.executionContext, usageCounter: this.usageCounter, + eventLoopDelayConfig: { ...this.config.event_loop_delay }, }); }; diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts new file mode 100644 index 0000000000000..5d72120da725c --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { startTaskTimer, startTaskTimerWithEventLoopMonitoring } from './task_events'; + +const DelayIterations = 4; +const DelayMillis = 250; +const DelayTotal = DelayIterations * DelayMillis; + +async function nonBlockingDelay(millis: number) { + await new Promise((resolve) => setTimeout(resolve, millis)); +} + +async function blockingDelay(millis: number) { + // get task in async queue + await nonBlockingDelay(0); + + const end = Date.now() + millis; + // eslint-disable-next-line no-empty + while (Date.now() < end) {} +} + +async function nonBlockingTask() { + for (let i = 0; i < DelayIterations; i++) { + await nonBlockingDelay(DelayMillis); + } +} + +async function blockingTask() { + for (let i = 0; i < DelayIterations; i++) { + await blockingDelay(DelayMillis); + } +} + +describe('task_events', () => { + test('startTaskTimer', async () => { + const stopTaskTimer = startTaskTimer(); + await nonBlockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBe(undefined); + }); + + describe('startTaskTimerWithEventLoopMonitoring', () => { + test('non-blocking', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: true, + warn_threshold: 5000, + }); + await nonBlockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBeLessThan(DelayMillis); + }); + + test('blocking', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: true, + warn_threshold: 5000, + }); + await blockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).not.toBeLessThan(DelayMillis); + }); + + test('not monitoring', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: false, + warn_threshold: 5000, + }); + await blockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index 7c7845569a10b..de2c9dc04acd2 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { monitorEventLoopDelay } from 'perf_hooks'; + import { Option } from 'fp-ts/lib/Option'; import { ConcreteTaskInstance } from './task'; @@ -14,6 +16,7 @@ import { ClaimAndFillPoolResult } from './lib/fill_pool'; import { PollingError } from './polling'; import { TaskRunResult } from './task_running'; import { EphemeralTaskInstanceRequest } from './ephemeral_task_lifecycle'; +import type { EventLoopDelayConfig } from './config'; export enum TaskPersistence { Recurring = 'recurring', @@ -40,6 +43,7 @@ export enum TaskClaimErrorType { export interface TaskTiming { start: number; stop: number; + eventLoopBlockMs?: number; } export type WithTaskTiming = T & { timing: TaskTiming }; @@ -48,6 +52,22 @@ export function startTaskTimer(): () => TaskTiming { return () => ({ start, stop: Date.now() }); } +export function startTaskTimerWithEventLoopMonitoring( + eventLoopDelayConfig: EventLoopDelayConfig +): () => TaskTiming { + const stopTaskTimer = startTaskTimer(); + const eldHistogram = eventLoopDelayConfig.monitor ? monitorEventLoopDelay() : null; + eldHistogram?.enable(); + + return () => { + const { start, stop } = stopTaskTimer(); + eldHistogram?.disable(); + const eldMax = eldHistogram?.max ?? 0; + const eventLoopBlockMs = Math.round(eldMax / 1000 / 1000); // original in nanoseconds + return { start, stop, eventLoopBlockMs }; + }; +} + export interface TaskEvent { id?: ID; timing?: TaskTiming; diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 09af125884fe9..ece82099728e3 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -1528,7 +1528,11 @@ describe('TaskManagerRunner', () => { function withAnyTiming(taskRun: TaskRun) { return { ...taskRun, - timing: { start: expect.any(Number), stop: expect.any(Number) }, + timing: { + start: expect.any(Number), + stop: expect.any(Number), + eventLoopBlockMs: expect.any(Number), + }, }; } @@ -1590,6 +1594,10 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, executionContext, usageCounter, + eventLoopDelayConfig: { + monitor: true, + warn_threshold: 5000, + }, }); if (stage === TaskRunningStage.READY_TO_RUN) { diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index 48927435c4bdf..778a834c168a1 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -38,7 +38,7 @@ import { TaskMarkRunning, asTaskRunEvent, asTaskMarkRunningEvent, - startTaskTimer, + startTaskTimerWithEventLoopMonitoring, TaskTiming, TaskPersistence, } from '../task_events'; @@ -56,6 +56,7 @@ import { } from '../task'; import { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError } from './errors'; +import type { EventLoopDelayConfig } from '../config'; const defaultBackoffPerFailure = 5 * 60 * 1000; export const EMPTY_RUN_RESULT: SuccessfulRunResult = { state: {} }; @@ -105,6 +106,7 @@ type Opts = { defaultMaxAttempts: number; executionContext: ExecutionContextStart; usageCounter?: UsageCounter; + eventLoopDelayConfig: EventLoopDelayConfig; } & Pick; export enum TaskRunResult { @@ -152,6 +154,7 @@ export class TaskManagerRunner implements TaskRunner { private uuid: string; private readonly executionContext: ExecutionContextStart; private usageCounter?: UsageCounter; + private eventLoopDelayConfig: EventLoopDelayConfig; /** * Creates an instance of TaskManagerRunner. @@ -174,6 +177,7 @@ export class TaskManagerRunner implements TaskRunner { onTaskEvent = identity, executionContext, usageCounter, + eventLoopDelayConfig, }: Opts) { this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; @@ -186,6 +190,7 @@ export class TaskManagerRunner implements TaskRunner { this.executionContext = executionContext; this.usageCounter = usageCounter; this.uuid = uuid.v4(); + this.eventLoopDelayConfig = eventLoopDelayConfig; } /** @@ -292,7 +297,7 @@ export class TaskManagerRunner implements TaskRunner { taskInstance: this.instance.task, }); - const stopTaskTimer = startTaskTimer(); + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring(this.eventLoopDelayConfig); try { this.task = this.definition.createTaskRunner(modifiedContext); @@ -617,6 +622,18 @@ export class TaskManagerRunner implements TaskRunner { ); } ); + + const { eventLoopBlockMs = 0 } = taskTiming; + const taskLabel = `${this.taskType} ${this.instance.task.id}`; + if (eventLoopBlockMs > this.eventLoopDelayConfig.warn_threshold) { + this.logger.warn( + `event loop blocked for at least ${eventLoopBlockMs} ms while running task ${taskLabel}`, + { + tags: [this.taskType, taskLabel, 'event-loop-blocked'], + } + ); + } + return result; } From e6f225bc56890c3bfd4992549c09f92a638ffb5c Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Wed, 23 Mar 2022 14:32:39 +0000 Subject: [PATCH 13/66] Add loading state to the SparkPlot (#128196) --- .../error_group_list/index.tsx | 11 +- .../app/error_group_overview/index.tsx | 7 +- .../app/service_inventory/index.tsx | 4 + .../service_inventory/service_list/index.tsx | 9 + .../service_list/service_list.test.tsx | 10 ++ .../app/service_map/popover/stats_list.tsx | 1 + .../get_columns.tsx | 3 + .../service_overview_errors_table/index.tsx | 5 + ...ice_overview_instances_chart_and_table.tsx | 90 +++++----- .../get_columns.tsx | 7 + .../index.tsx | 3 + .../shared/charts/spark_plot/index.tsx | 161 ++++++++++++------ .../shared/dependencies_table/index.tsx | 12 ++ .../shared/transactions_table/get_columns.tsx | 5 + .../shared/transactions_table/index.tsx | 8 +- 15 files changed, 236 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx index 7a54a633e7f15..6e86c8c951710 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx @@ -59,6 +59,7 @@ type ErrorGroupDetailedStatistics = interface Props { mainStatistics: ErrorGroupItem[]; serviceName: string; + detailedStatisticsLoading: boolean; detailedStatistics: ErrorGroupDetailedStatistics; comparisonEnabled?: boolean; } @@ -66,6 +67,7 @@ interface Props { function ErrorGroupList({ mainStatistics, serviceName, + detailedStatisticsLoading, detailedStatistics, comparisonEnabled, }: Props) { @@ -210,6 +212,7 @@ function ErrorGroupList({ return ( >; - }, [serviceName, query, detailedStatistics, comparisonEnabled]); + }, [ + serviceName, + query, + detailedStatistics, + comparisonEnabled, + detailedStatisticsLoading, + ]); return ( { if (requestId && errorGroupMainStatistics.length && start && end) { @@ -189,6 +190,10 @@ export function ErrorGroupOverview() { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index c26ae5a273b4e..807a848d649ea 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -191,6 +191,10 @@ export function ServiceInventory() { isLoading={isLoading} isFailure={isFailure} items={items} + comparisonDataLoading={ + comparisonFetch.status === FETCH_STATUS.LOADING || + comparisonFetch.status === FETCH_STATUS.NOT_INITIATED + } comparisonData={comparisonFetch?.data} noItemsMessage={noItemsMessage} /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 2d01a11d92186..cc43be6a790ea 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -64,6 +64,7 @@ const SERVICE_HEALTH_STATUS_ORDER = [ export function getServiceColumns({ query, showTransactionTypeColumn, + comparisonDataLoading, comparisonData, breakpoints, showHealthStatusColumn, @@ -71,6 +72,7 @@ export function getServiceColumns({ query: TypeOf['query']; showTransactionTypeColumn: boolean; showHealthStatusColumn: boolean; + comparisonDataLoading: boolean; breakpoints: Breakpoints; comparisonData?: ServicesDetailedStatisticsAPIResponse; }): Array> { @@ -162,6 +164,7 @@ export function getServiceColumns({ ); return ( { describe('when small', () => { it('shows environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -96,6 +97,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -105,6 +107,7 @@ describe('ServiceList', () => { describe('when Large', () => { it('hides environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -122,6 +125,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={true} + isLoading={false} valueLabel="0 ms" /> `); @@ -130,6 +134,7 @@ describe('ServiceList', () => { describe('when XL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -156,6 +161,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -165,6 +171,7 @@ describe('ServiceList', () => { describe('when XXL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -192,6 +199,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -203,6 +211,7 @@ describe('ServiceList', () => { describe('without ML data', () => { it('hides healthStatus column', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: false, query, showTransactionTypeColumn: true, @@ -219,6 +228,7 @@ describe('ServiceList', () => { describe('with ML data', () => { it('shows healthStatus column', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, diff --git a/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx b/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx index 7cc0e158fe52d..e5ed89571165e 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx @@ -198,6 +198,7 @@ export function StatsList({ data, isLoading }: StatsListProps) { {timeseries ? ( ['query']; @@ -129,6 +131,7 @@ export function getColumns({ return ( { if (requestId && items.length && start && end) { @@ -176,8 +177,12 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { { preservePreviousData: false } ); + const errorGroupDetailedStatisticsLoading = + errorGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING; + const columns = getColumns({ serviceName, + errorGroupDetailedStatisticsLoading, errorGroupDetailedStatistics, comparisonEnabled, query, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index dfea13eaaf476..bbe94f8e8aae8 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -172,50 +172,52 @@ export function ServiceOverviewInstancesChartAndTable({ direction ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); - const { data: detailedStatsData = INITIAL_STATE_DETAILED_STATISTICS } = - useFetcher( - (callApmApi) => { - if ( - !start || - !end || - !transactionType || - !latencyAggregationType || - !currentPeriodItemsCount - ) { - return; - } + const { + data: detailedStatsData = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatsStatus, + } = useFetcher( + (callApmApi) => { + if ( + !start || + !end || + !transactionType || + !latencyAggregationType || + !currentPeriodItemsCount + ) { + return; + } - return callApmApi( - 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', - { - params: { - path: { - serviceName, - }, - query: { - environment, - kuery, - latencyAggregationType: - latencyAggregationType as LatencyAggregationType, - start, - end, - numBuckets: 20, - transactionType, - serviceNodeIds: JSON.stringify( - currentPeriodOrderedItems.map((item) => item.serviceNodeName) - ), - comparisonStart, - comparisonEnd, - }, + return callApmApi( + 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', + { + params: { + path: { + serviceName, }, - } - ); - }, - // only fetches detailed statistics when requestId is invalidated by main statistics api call - // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], - { preservePreviousData: false } - ); + query: { + environment, + kuery, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, + start, + end, + numBuckets: 20, + transactionType, + serviceNodeIds: JSON.stringify( + currentPeriodOrderedItems.map((item) => item.serviceNodeName) + ), + comparisonStart, + comparisonEnd, + }, + }, + } + ); + }, + // only fetches detailed statistics when requestId is invalidated by main statistics api call + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId], + { preservePreviousData: false } + ); return ( <> @@ -233,6 +235,10 @@ export function ServiceOverviewInstancesChartAndTable({ mainStatsItems={currentPeriodOrderedItems} mainStatsStatus={mainStatsStatus} mainStatsItemCount={currentPeriodItemsCount} + detailedStatsLoading={ + detailedStatsStatus === FETCH_STATUS.LOADING || + detailedStatsStatus === FETCH_STATUS.NOT_INITIATED + } detailedStatsData={detailedStatsData} serviceName={serviceName} tableOptions={tableOptions} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index 0b6e846f95239..26a117224ace1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -48,6 +48,7 @@ export function getColumns({ kuery, agentName, latencyAggregationType, + detailedStatsLoading, detailedStatsData, comparisonEnabled, toggleRowDetails, @@ -60,6 +61,7 @@ export function getColumns({ kuery: string; agentName?: string; latencyAggregationType?: LatencyAggregationType; + detailedStatsLoading: boolean; detailedStatsData?: ServiceInstanceDetailedStatistics; comparisonEnabled?: boolean; toggleRowDetails: (selectedServiceNodeName: string) => void; @@ -125,6 +127,7 @@ export function getColumns({ color={currentPeriodColor} valueLabel={asMillisecondDuration(latency)} hideSeries={!shouldShowSparkPlots} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -158,6 +161,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asTransactionRate(throughput)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -191,6 +195,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(errorRate, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -224,6 +229,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(cpuUsage, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -257,6 +263,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(memoryUsage, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index c12b0a19644f5..b49208e2cdde7 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -53,6 +53,7 @@ interface Props { page?: { index: number }; sort?: { field: string; direction: SortDirection }; }) => void; + detailedStatsLoading: boolean; detailedStatsData?: ServiceInstanceDetailedStatistics; isLoading: boolean; isNotInitiated: boolean; @@ -64,6 +65,7 @@ export function ServiceOverviewInstancesTable({ mainStatsStatus: status, tableOptions, onChangeTableOptions, + detailedStatsLoading, detailedStatsData: detailedStatsData, isLoading, isNotInitiated, @@ -124,6 +126,7 @@ export function ServiceOverviewInstancesTable({ serviceName, kuery, latencyAggregationType: latencyAggregationType as LatencyAggregationType, + detailedStatsLoading, detailedStatsData, comparisonEnabled, toggleRowDetails, diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 325eb3d12f899..c497d35ed2cf6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -14,7 +14,12 @@ import { ScaleType, Settings, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingChart, +} from '@elastic/eui'; import React from 'react'; import { useChartTheme } from '../../../../../../observability/public'; import { Coordinate } from '../../../../../typings/timeseries'; @@ -32,6 +37,7 @@ const flexGroupStyle = { overflow: 'hidden' }; export function SparkPlot({ color, + isLoading, series, comparisonSeries = [], valueLabel, @@ -39,11 +45,52 @@ export function SparkPlot({ comparisonSeriesColor, }: { color: string; + isLoading: boolean; series?: Coordinate[] | null; valueLabel: React.ReactNode; compact?: boolean; comparisonSeries?: Coordinate[]; comparisonSeriesColor: string; +}) { + return ( + + + {valueLabel} + + + + + + ); +} + +function SparkPlotItem({ + color, + isLoading, + series, + comparisonSeries, + comparisonSeriesColor, + compact, +}: { + color: string; + isLoading: boolean; + series?: Coordinate[] | null; + compact?: boolean; + comparisonSeries?: Coordinate[]; + comparisonSeriesColor: string; }) { const theme = useTheme(); const defaultChartTheme = useChartTheme(); @@ -68,61 +115,65 @@ export function SparkPlot({ const Sparkline = hasComparisonSeries ? LineSeries : AreaSeries; + if (isLoading) { + return ( +
+ +
+ ); + } + + if (hasValidTimeseries(series)) { + return ( + + + + {hasComparisonSeries && ( + + )} + + ); + } + return ( - - - {valueLabel} - - - {hasValidTimeseries(series) ? ( - - - - {hasComparisonSeries && ( - - )} - - ) : ( -
- -
- )} -
-
+ +
); } diff --git a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx index 0986c8fe587de..a0dba6f5b870b 100644 --- a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx @@ -97,6 +97,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.latency.timeseries} comparisonSeries={previousStats?.latency.timeseries} valueLabel={asMillisecondDuration(currentStats.latency.value)} @@ -122,6 +126,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.throughput.timeseries} comparisonSeries={previousStats?.throughput.timeseries} valueLabel={asTransactionRate(currentStats.throughput.value)} @@ -168,6 +176,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.errorRate.timeseries} comparisonSeries={previousStats?.errorRate.timeseries} valueLabel={asPercent(currentStats.errorRate.value, 1)} diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx index ecfe277247d4c..054514f430a07 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx @@ -46,6 +46,7 @@ type TransactionGroupDetailedStatistics = export function getColumns({ serviceName, latencyAggregationType, + transactionGroupDetailedStatisticsLoading, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots = true, @@ -53,6 +54,7 @@ export function getColumns({ }: { serviceName: string; latencyAggregationType?: LatencyAggregationType; + transactionGroupDetailedStatisticsLoading: boolean; transactionGroupDetailedStatistics?: TransactionGroupDetailedStatistics; comparisonEnabled?: boolean; shouldShowSparkPlots?: boolean; @@ -106,6 +108,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined @@ -140,6 +143,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined @@ -196,6 +200,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 6134f9c3cdcb1..66f068f6cb05c 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -182,7 +182,10 @@ export function TransactionsTable({ }, } = data; - const { data: transactionGroupDetailedStatistics } = useFetcher( + const { + data: transactionGroupDetailedStatistics, + status: transactionGroupDetailedStatisticsStatus, + } = useFetcher( (callApmApi) => { if ( transactionGroupsTotalItems && @@ -225,6 +228,9 @@ export function TransactionsTable({ const columns = getColumns({ serviceName, latencyAggregationType: latencyAggregationType as LatencyAggregationType, + transactionGroupDetailedStatisticsLoading: + transactionGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING || + transactionGroupDetailedStatisticsStatus === FETCH_STATUS.NOT_INITIATED, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots, From d48b82ad5b2f156f0c9c4740f6caaa7cdeff3f29 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Wed, 23 Mar 2022 17:49:05 +0300 Subject: [PATCH 14/66] [RAC][APM] Add "View in App URL" {{context.viewInAppUrl}} variable to the rule templating language (#128243) * Add viewInAppUrl variable * export getAlertUrl as common function * add getAlertUrl to the error rule type * Add viewInAppUrl for Error rule type * Fix tests mocking * Add viewInAppUrl to tracation duration * Add viewInAppUrl for transaction duration * Add viewInAppUrl to anomolay alert * Fix funxtion arg * Add viewInAppUrl to TransactionDurationAnomaly * Get all related code to use the shared functions * Add/Fix tests * Update file name to snack case * Add comment * Fix lint * Remove join * Fix basePath mock * Code Review - refactor foramtting functions * Remove comment * fix typo --- .../apm/common/utils/formatters/alert_url.ts | 42 ++++++++++ .../apm/common/utils/formatters/index.ts | 1 + .../alerting/register_apm_alerts.ts | 81 +++++++------------ x-pack/plugins/apm/server/plugin.ts | 1 + .../server/routes/alerts/action_variables.ts | 10 +++ .../routes/alerts/register_apm_alerts.ts | 3 +- .../register_error_count_alert_type.test.ts | 6 ++ .../alerts/register_error_count_alert_type.ts | 19 ++++- ...er_transaction_duration_alert_type.test.ts | 2 + ...egister_transaction_duration_alert_type.ts | 18 +++++ ...action_duration_anomaly_alert_type.test.ts | 2 + ...transaction_duration_anomaly_alert_type.ts | 18 +++++ ..._transaction_error_rate_alert_type.test.ts | 2 + ...ister_transaction_error_rate_alert_type.ts | 16 ++++ .../server/routes/alerts/test_utils/index.ts | 7 +- 15 files changed, 172 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/apm/common/utils/formatters/alert_url.ts diff --git a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts new file mode 100644 index 0000000000000..a88f69b4ef5c7 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { stringify } from 'querystring'; +import { ENVIRONMENT_ALL } from '../../environment_filter_values'; + +const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; + +export const getAlertUrlErrorCount = ( + serviceName: string, + serviceEnv: string | undefined +) => + format({ + pathname: `/app/apm/services/${serviceName}/errors`, + query: { + environment: serviceEnv ?? ENVIRONMENT_ALL.value, + }, + }); +// This formatter is for TransactionDuration, TransactionErrorRate, and TransactionDurationAnomaly. +export const getAlertUrlTransaction = ( + serviceName: string, + serviceEnv: string | undefined, + transactionType: string +) => + format({ + pathname: `/app/apm/services/${serviceName}`, + query: { + transactionType, + environment: serviceEnv ?? ENVIRONMENT_ALL.value, + }, + }); diff --git a/x-pack/plugins/apm/common/utils/formatters/index.ts b/x-pack/plugins/apm/common/utils/formatters/index.ts index 1a431867308b6..f510a54b37102 100644 --- a/x-pack/plugins/apm/common/utils/formatters/index.ts +++ b/x-pack/plugins/apm/common/utils/formatters/index.ts @@ -9,3 +9,4 @@ export * from './formatters'; export * from './datetime'; export * from './duration'; export * from './size'; +export * from './alert_url'; diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 3be124573728b..692165f2b2ff5 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { stringify } from 'querystring'; import { ALERT_REASON } from '@kbn/rule-data-utils'; import type { ObservabilityRuleTypeRegistry } from '../../../../observability/public'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { + getAlertUrlErrorCount, + getAlertUrlTransaction, +} from '../../../common/utils/formatters'; import { AlertType } from '../../../common/alert_types'; // copied from elasticsearch_fieldnames.ts to limit page load bundle size @@ -18,16 +20,6 @@ const SERVICE_ENVIRONMENT = 'service.environment'; const SERVICE_NAME = 'service.name'; const TRANSACTION_TYPE = 'transaction.type'; -const format = ({ - pathname, - query, -}: { - pathname: string; - query: Record; -}): string => { - return `${pathname}?${stringify(query)}`; -}; - export function registerApmAlerts( observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry ) { @@ -40,16 +32,10 @@ export function registerApmAlerts( format: ({ fields }) => { return { reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String( - fields[SERVICE_NAME][0] - )}/errors`, - query: { - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlErrorCount( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]) + ), }; }, iconClass: 'bell', @@ -83,19 +69,16 @@ export function registerApmAlerts( 'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.', } ), - format: ({ fields, formatters: { asDuration } }) => ({ - reason: fields[ALERT_REASON]!, - - link: format({ - pathname: `/app/apm/services/${fields[SERVICE_NAME][0]!}`, - query: { - transactionType: fields[TRANSACTION_TYPE][0]!, - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), - }), + format: ({ fields, formatters: { asDuration } }) => { + return { + reason: fields[ALERT_REASON]!, + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), + }; + }, iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.links.alerting.apmRules}`; @@ -132,15 +115,11 @@ export function registerApmAlerts( ), format: ({ fields, formatters: { asPercent } }) => ({ reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String(fields[SERVICE_NAME][0]!)}`, - query: { - transactionType: String(fields[TRANSACTION_TYPE][0]!), - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), }), iconClass: 'bell', documentationUrl(docLinks) { @@ -177,15 +156,11 @@ export function registerApmAlerts( ), format: ({ fields }) => ({ reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String(fields[SERVICE_NAME][0])}`, - query: { - transactionType: String(fields[TRANSACTION_TYPE][0]), - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), }), iconClass: 'bell', documentationUrl(docLinks) { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 2dda29019239a..4e603741ea2a5 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -194,6 +194,7 @@ export class APMPlugin ml: plugins.ml, config$, logger: this.logger!.get('rule'), + basePath: core.http.basePath, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts index 540cd9ffd4946..ce78dbc7bee6d 100644 --- a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts +++ b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts @@ -65,4 +65,14 @@ export const apmActionVariables = { ), name: 'reason' as const, }, + viewInAppUrl: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.viewInAppUrl', + { + defaultMessage: + 'Link to the view or feature within Elastic that can be used to investigate the alert and its context further', + } + ), + name: 'viewInAppUrl' as const, + }, }; diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts index db79b4f11df29..4556abfea1ee5 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts @@ -6,7 +6,7 @@ */ import { Observable } from 'rxjs'; -import { Logger } from 'kibana/server'; +import { IBasePath, Logger } from 'kibana/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../alerting/server'; import { IRuleDataClient } from '../../../../rule_registry/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; @@ -22,6 +22,7 @@ export interface RegisterRuleDependencies { alerting: AlertingPluginSetupContract; config$: Observable; logger: Logger; + basePath: IBasePath; } export function registerApmAlerts(dependencies: RegisterRuleDependencies) { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts index 175b87f7943b0..3125791e7853b 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts @@ -144,6 +144,8 @@ describe('Error count alert', () => { triggerValue: 5, reason: 'Error count is 5 in the last 5 mins for foo. Alert when > 2.', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', @@ -152,6 +154,8 @@ describe('Error count alert', () => { triggerValue: 4, reason: 'Error count is 4 in the last 5 mins for foo. Alert when > 2.', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', @@ -160,6 +164,8 @@ describe('Error count alert', () => { threshold: 2, triggerValue: 3, interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts index f5df3c946f46e..5fc32ea363bc6 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts @@ -18,6 +18,7 @@ import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; +import { getAlertUrlErrorCount } from '../../../common/utils/formatters'; import { AlertType, APM_SERVER_FEATURE_ID, @@ -52,6 +53,7 @@ export function registerErrorCountAlertType({ logger, ruleDataClient, config$, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -75,6 +77,7 @@ export function registerErrorCountAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -83,11 +86,11 @@ export function registerErrorCountAlertType({ executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const ruleParams = params; + const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const searchParams = { index: indices.error, size: 0, @@ -147,6 +150,19 @@ export function registerErrorCountAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlErrorCount( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT] + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; + services .alertWithLifecycle({ id: [AlertType.ErrorCount, serviceName, environment] @@ -168,6 +184,7 @@ export function registerErrorCountAlertType({ triggerValue: errorCount, interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: alertReason, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts index 6a3feed69c19a..57b596bf94087 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts @@ -57,6 +57,8 @@ describe('registerTransactionDurationAlertType', () => { interval: `5m`, reason: 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts index 4567670129720..bfbb2a99c662c 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts @@ -13,6 +13,7 @@ import { ALERT_REASON, } from '@kbn/rule-data-utils'; import { take } from 'rxjs/operators'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { asDuration } from '../../../../observability/common/utils/formatters'; import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; import { SearchAggregatedTransactionSetting } from '../../../common/aggregated_transactions'; @@ -26,6 +27,7 @@ import { PROCESSOR_EVENT, SERVICE_NAME, TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { getEnvironmentEsField, @@ -64,6 +66,7 @@ export function registerTransactionDurationAlertType({ ruleDataClient, config$, logger, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -87,6 +90,7 @@ export function registerTransactionDurationAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -188,6 +192,19 @@ export function registerTransactionDurationAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + ruleParams.serviceName, + getEnvironmentEsField(ruleParams.environment)?.[SERVICE_ENVIRONMENT], + ruleParams.transactionType + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: `${AlertType.TransactionDuration}_${getEnvironmentLabel( @@ -211,6 +228,7 @@ export function registerTransactionDurationAlertType({ triggerValue: transactionDurationFormatted, interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: reasonMessage, + viewInAppUrl, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts index 585fadc348700..2bb8530ca03f6 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -201,6 +201,8 @@ describe('Transaction duration anomaly alert', () => { triggerValue: 'critical', reason: 'critical anomaly with a score of 80 was detected in the last 5 mins for foo.', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts index fbdd7f5e33f0a..64f06c9f638f1 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -22,7 +22,9 @@ import { PROCESSOR_EVENT, SERVICE_NAME, TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -63,6 +65,7 @@ export function registerTransactionDurationAnomalyAlertType({ ruleDataClient, alerting, ml, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ logger, @@ -86,6 +89,7 @@ export function registerTransactionDurationAnomalyAlertType({ apmActionVariables.threshold, apmActionVariables.triggerValue, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: 'apm', @@ -218,6 +222,19 @@ export function registerTransactionDurationAnomalyAlertType({ windowSize: params.windowSize, windowUnit: params.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: [ @@ -246,6 +263,7 @@ export function registerTransactionDurationAnomalyAlertType({ threshold: selectedOption?.label, triggerValue: severityLevel, reason: reasonMessage, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts index 36ec8e6ce205f..d3a024ec92a73 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts @@ -129,6 +129,8 @@ describe('Transaction error rate alert', () => { threshold: 10, triggerValue: '10', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts index 0f68e74a2a9bc..219f992ad15be 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts @@ -17,6 +17,7 @@ import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; import { AlertType, @@ -60,6 +61,7 @@ export function registerTransactionErrorRateAlertType({ ruleDataClient, logger, config$, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -84,6 +86,7 @@ export function registerTransactionErrorRateAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -207,6 +210,18 @@ export function registerTransactionErrorRateAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ); + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: [ @@ -235,6 +250,7 @@ export function registerTransactionErrorRateAlertType({ triggerValue: asDecimalOrInteger(errorRate), interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: reasonMessage, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts index a34b3cdb1334d..71a4e0d3d111e 100644 --- a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Logger } from 'kibana/server'; +import { IBasePath, Logger } from 'kibana/server'; import { of } from 'rxjs'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; import { IRuleDataClient } from '../../../../../rule_registry/server'; @@ -56,6 +56,11 @@ export const createRuleTypeMocks = () => { ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-observability.apm.alerts' ) as IRuleDataClient, + basePath: { + serverBasePath: '/eyr', + publicBaseUrl: 'http://localhost:5601/eyr', + prepend: (path: string) => `http://localhost:5601/eyr${path}`, + } as IBasePath, }, services, scheduleActions, From c55bb917fab509df7922ac727d4246514dbd7a59 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Wed, 23 Mar 2022 08:23:22 -0700 Subject: [PATCH 15/66] [Security team: AWP] Session view: Alert details tab (#127500) * alerts tab work. list view done * View mode toggle + group view implemented * tests written * clean up * addressed @opauloh comments * fixed weird bug due to importing assests from a test into its component * empty state added for alerts tab * react-query caching keys updated to include sessionEntityId * rule_registry added as a dependency in order to use AlertsClient in alerts_route.ts * fixed build/test errors due to merge. events route now orders by process.start then @timestamp * plumbing for the alert details tie in done. * removed rule_registry ecs mappings. kqualters PR will add this. * alerts index merge conflict fix Co-authored-by: mitodrummer --- .../plugins/session_view/common/constants.ts | 3 +- x-pack/plugins/session_view/kibana.json | 7 +- .../detail_panel_alert_actions/index.test.tsx | 88 ++++++ .../detail_panel_alert_actions/index.tsx | 105 ++++++++ .../detail_panel_alert_actions/styles.ts | 107 ++++++++ .../detail_panel_alert_group_item/index.tsx | 84 ++++++ .../detail_panel_alert_list_item/index.tsx | 137 ++++++++++ .../detail_panel_alert_list_item/styles.ts | 112 ++++++++ .../detail_panel_alert_tab/index.test.tsx | 251 ++++++++++++++++++ .../detail_panel_alert_tab/index.tsx | 146 ++++++++++ .../detail_panel_alert_tab/styles.ts | 43 +++ .../public/components/process_tree/hooks.ts | 13 + .../components/process_tree/index.test.tsx | 5 +- .../public/components/process_tree/index.tsx | 12 +- .../process_tree_alert/index.test.tsx | 10 +- .../components/process_tree_alert/index.tsx | 12 +- .../process_tree_alerts/index.test.tsx | 2 +- .../components/process_tree_alerts/index.tsx | 9 +- .../process_tree_node/index.test.tsx | 2 +- .../components/process_tree_node/index.tsx | 48 +++- .../public/components/session_view/hooks.ts | 35 ++- .../public/components/session_view/index.tsx | 34 ++- .../public/components/session_view/styles.ts | 11 + .../session_view_detail_panel/index.test.tsx | 48 +++- .../session_view_detail_panel/index.tsx | 71 +++-- x-pack/plugins/session_view/server/plugin.ts | 12 +- .../server/routes/alerts_route.test.ts | 133 ++++++++++ .../server/routes/alerts_route.ts | 66 +++++ .../session_view/server/routes/index.ts | 5 +- .../server/routes/process_events_route.ts | 28 +- x-pack/plugins/session_view/server/types.ts | 15 +- x-pack/plugins/session_view/tsconfig.json | 3 +- 32 files changed, 1555 insertions(+), 102 deletions(-) create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts create mode 100644 x-pack/plugins/session_view/server/routes/alerts_route.test.ts create mode 100644 x-pack/plugins/session_view/server/routes/alerts_route.ts diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 42e1d33ab6dba..9e8e1ae0d5e04 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,10 +6,11 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERTS_ROUTE = '/internal/session_view/alerts_route'; export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status_route'; export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; -export const ALERTS_INDEX = '.siem-signals-default'; +export const ALERTS_INDEX = '.alerts-security.alerts-default'; // TODO: changes to remove this and use AlertsClient instead to get indices. export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json index ff9d849016c55..4807315569d34 100644 --- a/x-pack/plugins/session_view/kibana.json +++ b/x-pack/plugins/session_view/kibana.json @@ -1,6 +1,6 @@ { "id": "sessionView", - "version": "8.0.0", + "version": "1.0.0", "kibanaVersion": "kibana", "owner": { "name": "Security Team", @@ -8,10 +8,11 @@ }, "requiredPlugins": [ "data", - "timelines" + "timelines", + "ruleRegistry" ], "requiredBundles": [ - "kibanaReact", + "kibanaReact", "esUiShared" ], "server": true, diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx new file mode 100644 index 0000000000000..1d0c9d0227699 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { + DetailPanelAlertActions, + BUTTON_TEST_ID, + SHOW_DETAILS_TEST_ID, + JUMP_TO_PROCESS_TEST_ID, +} from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import userEvent from '@testing-library/user-event'; +import { ProcessImpl } from '../process_tree/hooks'; + +describe('DetailPanelAlertActions component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockShowAlertDetails = jest.fn((uuid) => uuid); + let mockOnProcessSelected = jest.fn((process) => process); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockShowAlertDetails = jest.fn((uuid) => uuid); + mockOnProcessSelected = jest.fn((process) => process); + }); + + describe('When DetailPanelAlertActions is mounted', () => { + it('renders a popover when button is clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(SHOW_DETAILS_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(JUMP_TO_PROCESS_TEST_ID)).toBeTruthy(); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls alert flyout callback when View details clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(SHOW_DETAILS_TEST_ID)); + expect(mockShowAlertDetails.mock.calls.length).toBe(1); + expect(mockShowAlertDetails.mock.results[0].value).toBe(mockEvent.kibana?.alert.uuid); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls onProcessSelected when Jump to process clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(JUMP_TO_PROCESS_TEST_ID)); + expect(mockOnProcessSelected.mock.calls.length).toBe(1); + expect(mockOnProcessSelected.mock.results[0].value).toBeInstanceOf(ProcessImpl); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx new file mode 100644 index 0000000000000..4c7e3fdfaa961 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPopover, EuiContextMenuPanel, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { ProcessImpl } from '../process_tree/hooks'; + +export const BUTTON_TEST_ID = 'sessionView:detailPanelAlertActionsBtn'; +export const SHOW_DETAILS_TEST_ID = 'sessionView:detailPanelAlertActionShowDetails'; +export const JUMP_TO_PROCESS_TEST_ID = 'sessionView:detailPanelAlertActionJumpToProcess'; + +interface DetailPanelAlertActionsDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; +} + +/** + * Detail panel alert context menu actions + */ +export const DetailPanelAlertActions = ({ + event, + onShowAlertDetails, + onProcessSelected, +}: DetailPanelAlertActionsDeps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const onClosePopover = useCallback(() => { + setPopover(false); + }, []); + + const onToggleMenu = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const onJumpToAlert = useCallback(() => { + const process = new ProcessImpl(event.process.entity_id); + process.addEvent(event); + + onProcessSelected(process); + setPopover(false); + }, [event, onProcessSelected]); + + const onShowDetails = useCallback(() => { + if (event.kibana) { + onShowAlertDetails(event.kibana.alert.uuid); + setPopover(false); + } + }, [event, onShowAlertDetails]); + + if (!event.kibana) { + return null; + } + + const { uuid } = event.kibana.alert; + + const menuItems = [ + + + , + + + , + ]; + + return ( + + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts new file mode 100644 index 0000000000000..14d0be374b5d1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +interface StylesDeps { + minimal?: boolean; + isInvestigated?: boolean; +} + +export const useStyles = ({ minimal = false, isInvestigated = false }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: mediumPadding, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx new file mode 100644 index 0000000000000..daa472cd6e5b4 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo } from 'react'; +import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from '../detail_panel_alert_list_item/styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; + +export const ALERT_GROUP_ITEM_TEST_ID = 'sessionView:detailPanelAlertGroupItem'; +export const ALERT_GROUP_ITEM_COUNT_TEST_ID = 'sessionView:detailPanelAlertGroupCount'; +export const ALERT_GROUP_ITEM_TITLE_TEST_ID = 'sessionView:detailPanelAlertGroupTitle'; + +interface DetailPanelAlertsGroupItemDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertGroupItem = ({ + alerts, + onProcessSelected, + onShowAlertDetails, +}: DetailPanelAlertsGroupItemDeps) => { + const styles = useStyles(); + + const alertsCount = useMemo(() => { + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + if (!alerts[0].kibana) { + return null; + } + + const { rule } = alerts[0].kibana.alert; + + return ( + +

+ + {rule.name} +

+ + } + css={styles.alertItem} + extraAction={ + + {alertsCount} + + } + > + {alerts.map((event) => { + const key = 'minimal_' + event.kibana?.alert.uuid; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx new file mode 100644 index 0000000000000..516d04539432e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiIcon, + EuiText, + EuiAccordion, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertActions } from '../detail_panel_alert_actions'; + +export const ALERT_LIST_ITEM_TEST_ID = 'sessionView:detailPanelAlertListItem'; +export const ALERT_LIST_ITEM_ARGS_TEST_ID = 'sessionView:detailPanelAlertListItemArgs'; +export const ALERT_LIST_ITEM_TIMESTAMP_TEST_ID = 'sessionView:detailPanelAlertListItemTimestamp'; + +interface DetailPanelAlertsListItemDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; + isInvestigated?: boolean; + minimal?: boolean; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertListItem = ({ + event, + onProcessSelected, + onShowAlertDetails, + isInvestigated, + minimal, +}: DetailPanelAlertsListItemDeps) => { + const styles = useStyles(minimal, isInvestigated); + + if (!event.kibana) { + return null; + } + + const timestamp = event['@timestamp']; + const { uuid, name } = event.kibana.alert.rule; + const { args } = event.process; + + const forceState = !isInvestigated ? 'open' : undefined; + + return minimal ? ( +
+ + + + + {timestamp} + + + + + + + + {args.join(' ')} + + +
+ ) : ( + +

+ + {name} +

+ + } + initialIsOpen={true} + forceState={forceState} + css={styles.alertItem} + extraAction={ + + } + > + + + {timestamp} + + + + {args.join(' ')} + + + {isInvestigated && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts new file mode 100644 index 0000000000000..7672bb942ff32 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +export const useStyles = (minimal = false, isInvestigated = false) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: minimal ? size.s : size.m, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + const minimalContextMenu: CSSObject = { + float: 'right', + }; + + const minimalHR: CSSObject = { + marginBottom: 0, + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + minimalContextMenu, + minimalHR, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx new file mode 100644 index 0000000000000..a915f8e285ad1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAlertTab } from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { fireEvent } from '@testing-library/dom'; +import { + INVESTIGATED_ALERT_TEST_ID, + VIEW_MODE_TOGGLE, + ALERTS_TAB_EMPTY_STATE_TEST_ID, +} from './index'; +import { + ALERT_LIST_ITEM_TEST_ID, + ALERT_LIST_ITEM_ARGS_TEST_ID, + ALERT_LIST_ITEM_TIMESTAMP_TEST_ID, +} from '../detail_panel_alert_list_item/index'; +import { + ALERT_GROUP_ITEM_TEST_ID, + ALERT_GROUP_ITEM_COUNT_TEST_ID, + ALERT_GROUP_ITEM_TITLE_TEST_ID, +} from '../detail_panel_alert_group_item/index'; + +const ACCORDION_BUTTON_CLASS = '.euiAccordion__button'; +const VIEW_MODE_GROUP = 'groupView'; +const ARIA_EXPANDED_ATTR = 'aria-expanded'; + +describe('DetailPanelAlertTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); + }); + + describe('When DetailPanelAlertTab is mounted', () => { + it('renders a list of alerts for the session (defaulting to list view mode)', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('List view'); + }); + + it('renders a list of alerts grouped by rule when group-view clicked', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('Group view'); + }); + + it('renders a sticky investigated alert (outside of main list) if one is set', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + }); + + it('investigated alert should be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + }); + + it('non investigated alert should NOT be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('each alert list item should show a timestamp and process arguments', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0]['@timestamp'] + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_ARGS_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0].process.args.join(' ') + ); + }); + + it('each alert group should show a rule title and alert count', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_COUNT_TEST_ID)).toHaveTextContent('2'); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TITLE_TEST_ID)).toHaveTextContent( + mockAlerts[0].kibana?.alert.rule.name || '' + ); + }); + + it('renders an empty state when there are no alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx new file mode 100644 index 0000000000000..7fa47f4f5daf7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useMemo } from 'react'; +import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { groupBy } from 'lodash'; +import { ProcessEvent, Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { DetailPanelAlertGroupItem } from '../detail_panel_alert_group_item'; + +export const ALERTS_TAB_EMPTY_STATE_TEST_ID = 'sessionView:detailPanelAlertsEmptyState'; +export const INVESTIGATED_ALERT_TEST_ID = 'sessionView:detailPanelInvestigatedAlert'; +export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode'; + +interface DetailPanelAlertTabDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; + investigatedAlert?: ProcessEvent; +} + +const VIEW_MODE_LIST = 'listView'; +const VIEW_MODE_GROUP = 'groupView'; + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelAlertTab = ({ + alerts, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, +}: DetailPanelAlertTabDeps) => { + const styles = useStyles(); + const [viewMode, setViewMode] = useState(VIEW_MODE_LIST); + const viewModes = [ + { + id: VIEW_MODE_LIST, + label: i18n.translate('xpack.sessionView.alertDetailsTab.listView', { + defaultMessage: 'List view', + }), + }, + { + id: VIEW_MODE_GROUP, + label: i18n.translate('xpack.sessionView.alertDetailsTab.groupView', { + defaultMessage: 'Group view', + }), + }, + ]; + + const filteredAlerts = useMemo(() => { + return alerts.filter((event) => { + const isInvestigatedAlert = + event.kibana?.alert.uuid === investigatedAlert?.kibana?.alert.uuid; + return !isInvestigatedAlert; + }); + }, [investigatedAlert, alerts]); + + const groupedAlerts = useMemo(() => { + return groupBy(filteredAlerts, (event) => event.kibana?.alert.rule.uuid); + }, [filteredAlerts]); + + if (alerts.length === 0) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( +
+ + {investigatedAlert && ( +
+ + +
+ )} + + {viewMode === VIEW_MODE_LIST + ? filteredAlerts.map((event) => { + const key = event.kibana?.alert.uuid; + + return ( + + ); + }) + : Object.keys(groupedAlerts).map((ruleId: string) => { + const alertsByRule = groupedAlerts[ruleId]; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts new file mode 100644 index 0000000000000..a906744cdafb2 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, size } = euiTheme; + + const container: CSSObject = { + position: 'relative', + }; + + const stickyItem: CSSObject = { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: colors.emptyShade, + paddingTop: size.base, + }; + + const viewMode: CSSObject = { + margin: size.base, + marginBottom: 0, + }; + + return { + container, + stickyItem, + viewMode, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index fb00344d5e280..2b7f78e88fafb 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -21,12 +21,14 @@ import { processNewEvents, searchProcessTree, autoExpandProcessTree, + updateProcessMap, } from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; searchQuery?: string; updatedAlertsStatus: AlertStatusEventEntityIdMap; } @@ -196,6 +198,7 @@ export class ProcessImpl implements Process { export const useProcessTree = ({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }: UseProcessTreeDeps) => { @@ -221,6 +224,7 @@ export const useProcessTree = ({ const [processMap, setProcessMap] = useState(initializedProcessMap); const [processedPages, setProcessedPages] = useState([]); + const [alertsProcessed, setAlertsProcessed] = useState(false); const [searchResults, setSearchResults] = useState([]); const [orphans, setOrphans] = useState([]); @@ -257,6 +261,15 @@ export const useProcessTree = ({ } }, [data, processMap, orphans, processedPages, sessionEntityId]); + useEffect(() => { + // currently we are loading a single page of alerts, with no pagination + // so we only need to add these alert events to processMap once. + if (!alertsProcessed) { + updateProcessMap(processMap, alerts); + setAlertsProcessed(true); + } + }, [processMap, alerts, alertsProcessed]); + useEffect(() => { setSearchResults(searchProcessTree(processMap, searchQuery)); autoExpandProcessTree(processMap); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index 9fa7900d04b0d..3c0b9c5d0d4d9 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { mockData, mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; @@ -21,6 +21,7 @@ describe('ProcessTree component', () => { const props: ProcessTreeDeps = { sessionEntityId: sessionLeader.process.entity_id, data: mockData, + alerts: mockAlerts, isFetching: false, fetchNextPage: jest.fn(), hasNextPage: false, @@ -28,7 +29,7 @@ describe('ProcessTree component', () => { hasPreviousPage: false, onProcessSelected: jest.fn(), updatedAlertsStatus: {}, - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 4b489797c7e26..1e10e58d1cca0 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -26,6 +26,7 @@ export interface ProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; jumpToEvent?: ProcessEvent; isFetching: boolean; @@ -44,8 +45,7 @@ export interface ProcessTreeDeps { // a map for alerts with updated status and process.entity_id updatedAlertsStatus: AlertStatusEventEntityIdMap; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +53,7 @@ export interface ProcessTreeDeps { export const ProcessTree = ({ sessionEntityId, data, + alerts, jumpToEvent, isFetching, hasNextPage, @@ -64,8 +65,7 @@ export const ProcessTree = ({ onProcessSelected, setSearchResults, updatedAlertsStatus, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -76,6 +76,7 @@ export const ProcessTree = ({ const { sessionLeader, processMap, searchResults } = useProcessTree({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }); @@ -203,8 +204,7 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} timeStampOn={timeStampOn} verboseModeOn={verboseModeOn} /> diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 2a56a0ae2be67..c1b0c807528ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -26,7 +26,7 @@ describe('ProcessTreeAlerts component', () => { isSelected: false, onClick: jest.fn(), selectAlert: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { @@ -61,16 +61,16 @@ describe('ProcessTreeAlerts component', () => { expect(selectAlert).toHaveBeenCalledTimes(1); }); - it('should execute loadAlertDetails callback when clicking on expand button', async () => { - const loadAlertDetails = jest.fn(); + it('should execute onShowAlertDetails callback when clicking on expand button', async () => { + const onShowAlertDetails = jest.fn(); renderResult = mockedContext.render( - + ); const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); expect(expandButton).toBeTruthy(); expandButton?.click(); - expect(loadAlertDetails).toHaveBeenCalledTimes(1); + expect(onShowAlertDetails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index 5ec1c4a7693c3..30892d02c5428 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -17,8 +17,7 @@ export interface ProcessTreeAlertDeps { isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export const ProcessTreeAlert = ({ @@ -27,8 +26,7 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); @@ -41,10 +39,10 @@ export const ProcessTreeAlert = ({ }, [isInvestigated, uuid, selectAlert]); const handleExpandClick = useCallback(() => { - if (loadAlertDetails && uuid) { - loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + if (uuid) { + onShowAlertDetails(uuid); } - }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + }, [onShowAlertDetails, uuid]); const handleClick = useCallback(() => { if (alert.kibana?.alert) { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index 2333c71d36a51..ee6866f6a8a60 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -17,7 +17,7 @@ describe('ProcessTreeAlerts component', () => { const props: ProcessTreeAlertsDeps = { alerts: mockAlerts, onAlertSelected: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index c97ccfe253605..b51d58bf825ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -16,8 +16,7 @@ export interface ProcessTreeAlertsDeps { jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -25,8 +24,7 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -90,8 +88,7 @@ export function ProcessTreeAlerts({ isSelected={isProcessSelected && selectedAlert?.uuid === alertUuid} onClick={handleAlertClick} selectAlert={selectAlert} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 2e82e822f0c82..5c3b790ad0430 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -36,7 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), - handleOnAlertDetailsClosed: (_alertUuid: string) => {}, + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index b1c42dd95efb9..387e7a5074699 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -21,7 +21,8 @@ import React, { useMemo, RefObject, } from 'react'; -import { EuiButton, EuiIcon, formatDate } from '@elastic/eui'; +import { EuiButton, EuiIcon, EuiToolTip, formatDate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { Process } from '../../../common/types/process_tree'; import { useVisible } from '../../hooks/use_visible'; @@ -43,8 +44,7 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } /** @@ -62,8 +62,7 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessDeps) { const textRef = useRef(null); @@ -144,6 +143,33 @@ export function ProcessTreeNode({ ); const processDetails = process.getDetails(); + const hasExec = process.hasExec(); + + const processIcon = useMemo(() => { + if (!process.parent) { + return 'unlink'; + } else if (hasExec) { + return 'console'; + } else { + return 'branch'; + } + }, [hasExec, process.parent]); + + const iconTooltip = useMemo(() => { + if (!process.parent) { + return i18n.translate('xpack.sessionView.processNode.tooltipOrphan', { + defaultMessage: 'Process missing parent (orphan)', + }); + } else if (hasExec) { + return i18n.translate('xpack.sessionView.processNode.tooltipExec', { + defaultMessage: "Process exec'd", + }); + } else { + return i18n.translate('xpack.sessionView.processNode.tooltipFork', { + defaultMessage: 'Process forked (no exec)', + }); + } + }, [hasExec, process.parent]); if (!processDetails?.process) { return null; @@ -169,11 +195,9 @@ export function ProcessTreeNode({ const showUserEscalation = user.id !== parent.user.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; - const hasExec = process.hasExec(); const iconTestSubj = hasExec ? 'sessionView:processTreeNodeExecIcon' : 'sessionView:processTreeNodeForkIcon'; - const processIcon = hasExec ? 'console' : 'branch'; const timeStampsNormal = formatDate(start, KIBANA_DATE_FORMAT); @@ -200,7 +224,9 @@ export function ProcessTreeNode({ ) : ( - + + + {' '} {workingDirectory}  {args[0]}  @@ -255,8 +281,7 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> )} @@ -276,8 +301,7 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index a134a366c4168..bf8796336602d 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -15,9 +15,11 @@ import { ProcessEventResults, } from '../../../common/types/process_tree'; import { + ALERTS_ROUTE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, ALERT_STATUS_ROUTE, + QUERY_KEY_PROCESS_EVENTS, QUERY_KEY_ALERTS, } from '../../../common/constants'; @@ -28,9 +30,10 @@ export const useFetchSessionViewProcessEvents = ( const { http } = useKibana().services; const [isJumpToFirstPage, setIsJumpToFirstPage] = useState(false); const jumpToCursor = jumpToEvent && jumpToEvent.process.start; + const cachingKeys = [QUERY_KEY_PROCESS_EVENTS, sessionEntityId]; const query = useInfiniteQuery( - 'sessionViewProcessEvents', + cachingKeys, async ({ pageParam = {} }) => { let { cursor } = pageParam; const { forward } = pageParam; @@ -52,7 +55,7 @@ export const useFetchSessionViewProcessEvents = ( return { events, cursor }; }, { - getNextPageParam: (lastPage, pages) => { + getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], @@ -60,7 +63,7 @@ export const useFetchSessionViewProcessEvents = ( }; } }, - getPreviousPageParam: (firstPage, pages) => { + getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: firstPage.events[0]['@timestamp'], @@ -84,6 +87,32 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchSessionViewAlerts = (sessionEntityId: string) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId]; + const query = useQuery( + cachingKeys, + async () => { + const res = await http.get(ALERTS_ROUTE, { + query: { + sessionEntityId, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return events; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useFetchAlertStatus = ( updatedAlertsStatus: AlertStatusEventEntityIdMap, alertUuid: string diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index af4eb6114a0a2..ee481c4204108 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -23,7 +23,11 @@ import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { SessionViewDisplayOptions } from '../session_view_display_options'; import { useStyles } from './styles'; -import { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; +import { + useFetchAlertStatus, + useFetchSessionViewProcessEvents, + useFetchSessionViewAlerts, +} from './hooks'; /** * The main wrapper component for the session view. @@ -61,8 +65,12 @@ export const SessionView = ({ hasPreviousPage, } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); - const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; - const renderIsLoading = isFetching && !data; + const alertsQuery = useFetchSessionViewAlerts(sessionEntityId); + const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery; + + const hasData = alerts && data && data.pages?.[0].events.length > 0; + const hasError = error || alertsError; + const renderIsLoading = (isFetching || alertsFetching) && !data; const renderDetails = isDetailOpen && selectedProcess; const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( updatedAlertsStatus, @@ -83,6 +91,15 @@ export const SessionView = ({ setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const onShowAlertDetails = useCallback( + (alertUuid: string) => { + if (loadAlertDetails) { + loadAlertDetails(alertUuid, () => handleOnAlertDetailsClosed(alertUuid)); + } + }, + [loadAlertDetails, handleOnAlertDetailsClosed] + ); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -165,7 +182,7 @@ export const SessionView = ({ )} - {error && ( + {hasError && ( @@ -215,7 +232,7 @@ export const SessionView = ({ {renderDetails ? ( <> - + diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts index d2c87130bfa4b..edfe2356d5aa2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/styles.ts +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -17,6 +17,10 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { + const { border, colors } = euiTheme; + + const thinBorder = `${border.width.thin} solid ${colors.lightShade}!important`; + const processTree: CSSObject = { height: `${height}px`, position: 'relative', @@ -24,6 +28,12 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const detailPanel: CSSObject = { height: `${height}px`, + borderLeft: thinBorder, + borderRight: thinBorder, + }; + + const resizeHandle: CSSObject = { + zIndex: 2, }; const searchBar: CSSObject = { @@ -38,6 +48,7 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { return { processTree, detailPanel, + resizeHandle, searchBar, buttonsEyeDetail, }; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx index f754086fe5fab..40e71efd8a6cf 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -6,7 +6,10 @@ */ import React from 'react'; -import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { + mockAlerts, + sessionViewBasicProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { SessionViewDetailPanel } from './index'; @@ -14,27 +17,66 @@ describe('SessionView component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); beforeEach(() => { mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); }); describe('When SessionViewDetailPanel is mounted', () => { it('shows process detail by default', async () => { renderResult = mockedContext.render( - + ); expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); }); it('can switch tabs to show host details', async () => { renderResult = mockedContext.render( - + ); renderResult.queryByText('Host')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); }); + + it('can switch tabs to show alert details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeVisible(); + }); + it('alert tab disabled when no alerts', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index a47ce1d91ac97..51eb65a38f835 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -6,50 +6,91 @@ */ import React, { useState, useMemo, useCallback } from 'react'; import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { EuiTabProps } from '../../types'; -import { Process } from '../../../common/types/process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; import { DetailPanelProcessTab } from '../detail_panel_process_tab'; import { DetailPanelHostTab } from '../detail_panel_host_tab'; +import { DetailPanelAlertTab } from '../detail_panel_alert_tab'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; interface SessionViewDetailPanelDeps { selectedProcess: Process; - onProcessSelected?: (process: Process) => void; + alerts?: ProcessEvent[]; + investigatedAlert?: ProcessEvent; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; } /** * Detail panel in the session view. */ -export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { +export const SessionViewDetailPanel = ({ + alerts, + selectedProcess, + investigatedAlert, + onProcessSelected, + onShowAlertDetails, +}: SessionViewDetailPanelDeps) => { const [selectedTabId, setSelectedTabId] = useState('process'); const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); - const tabs: EuiTabProps[] = useMemo( - () => [ + const alertsCount = useMemo(() => { + if (!alerts) { + return 0; + } + + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + const tabs: EuiTabProps[] = useMemo(() => { + const hasAlerts = !!alerts?.length; + + return [ { id: 'process', - name: 'Process', + name: i18n.translate('xpack.sessionView.detailsPanel.process', { + defaultMessage: 'Process', + }), content: , }, { id: 'host', - name: 'Host', + name: i18n.translate('xpack.sessionView.detailsPanel.host', { + defaultMessage: 'Host', + }), content: , }, { id: 'alerts', - disabled: true, - name: 'Alerts', - append: ( + name: i18n.translate('xpack.sessionView.detailsPanel.alerts', { + defaultMessage: 'Alerts', + }), + append: hasAlerts && ( - 10 + {alertsCount} ), - content: null, + content: alerts && ( + + ), }, - ], - [processDetail, selectedProcess.events] - ); + ]; + }, [ + alerts, + alertsCount, + processDetail, + selectedProcess.events, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, + ]); const onSelectedTabChanged = useCallback((id: string) => { setSelectedTabId(id); diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts index c7fd511b3de05..7347f7676af62 100644 --- a/x-pack/plugins/session_view/server/plugin.ts +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -11,12 +11,14 @@ import { Plugin, Logger, PluginInitializerContext, + IRouter, } from '../../../../src/core/server'; import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; import { registerRoutes } from './routes'; export class SessionViewPlugin implements Plugin { private logger: Logger; + private router: IRouter | undefined; /** * Initialize SessionViewPlugin class properties (logger, etc) that is accessible @@ -28,14 +30,16 @@ export class SessionViewPlugin implements Plugin { public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { this.logger.debug('session view: Setup'); - const router = core.http.createRouter(); - - // Register server routes - registerRoutes(router); + this.router = core.http.createRouter(); } public start(core: CoreStart, plugins: SessionViewStartPlugins) { this.logger.debug('session view: Start'); + + // Register server routes + if (this.router) { + registerRoutes(this.router, plugins.ruleRegistry); + } } public stop() { diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts new file mode 100644 index 0000000000000..4c8ee6fb2c83e --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + ALERT_RULE_CONSUMER, + ALERT_RULE_TYPE_ID, + SPACE_IDS, + ALERT_WORKFLOW_STATUS, +} from '@kbn/rule-data-utils'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './alerts_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +import { + AlertsClient, + ConstructorOptions, +} from '../../../rule_registry/server/alert_data_client/alerts_client'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; +import { auditLoggerMock } from '../../../security/server/audit/mocks'; +import { AlertingAuthorizationEntity } from '../../../alerting/server'; +import { ruleDataServiceMock } from '../../../rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const DEFAULT_SPACE = 'test_default_space_id'; + +const getEmptyResponse = async () => { + return { + hits: { + total: 0, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: mockEvents.length, + hits: mockEvents.map((event) => { + return { + found: true, + _type: 'alert', + _index: '.alerts-security', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + ...event, + }, + }; + }), + }, + }; +}; + +const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + auditLogger, + ruleDataService: ruleDataServiceMock.create(), + esClient: esClientMock, +}; + +describe('alerts_route.ts', () => { + beforeEach(() => { + jest.resetAllMocks(); + + alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); + // @ts-expect-error + alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => + Promise.resolve({ filter: [] }) + ); + // @ts-expect-error + alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { + const authorizedRuleTypes = new Set(); + authorizedRuleTypes.add({ producer: 'apm' }); + return Promise.resolve({ authorizedRuleTypes }); + }); + + alertingAuthMock.ensureAuthorized.mockImplementation( + // @ts-expect-error + async ({ + ruleTypeId, + consumer, + operation, + entity, + }: { + ruleTypeId: string; + consumer: string; + operation: string; + entity: typeof AlertingAuthorizationEntity.Alert; + }) => { + if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { + return Promise.resolve(); + } + return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); + } + ); + }); + + describe('doSearch(client, sessionEntityId)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(mockEvents.length); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.ts b/x-pack/plugins/session_view/server/routes/alerts_route.ts new file mode 100644 index 0000000000000..3d03cb5cb8214 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../src/core/server'; +import { + ALERTS_ROUTE, + ALERTS_PER_PAGE, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; +import type { AlertsClient, RuleRegistryPluginStartContract } from '../../../rule_registry/server'; + +export const registerAlertsRoute = ( + router: IRouter, + ruleRegistry: RuleRegistryPluginStartContract +) => { + router.get( + { + path: ALERTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = await ruleRegistry.getRacClientWithRequest(request); + const { sessionEntityId } = request.query; + const body = await doSearch(client, sessionEntityId); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async (client: AlertsClient, sessionEntityId: string) => { + const indices = await client.getAuthorizedAlertsIndices(['siem']); + + if (!indices) { + return { events: [] }; + } + + const results = await client.find({ + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + track_total_hits: false, + size: ALERTS_PER_PAGE, + index: indices.join(','), + }); + + const events = results.hits.hits.map((hit: any) => { + // the alert indexes flattens many properties. this util unflattens them as session view expects structured json. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + return { events }; +}; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index b8cb80dc1d1d4..17efeb5d07a7b 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,11 +6,14 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertsRoute } from './alerts_route'; import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; +import { RuleRegistryPluginStartContract } from '../../../rule_registry/server'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => { registerProcessEventsRoute(router); registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); + registerAlertsRoute(router, ruleRegistry); }; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 47e2d917733d5..7be1885c70ab1 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -11,10 +11,8 @@ import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, PROCESS_EVENTS_INDEX, - ALERTS_INDEX, ENTRY_SESSION_ENTITY_ID_PROPERTY, } from '../../common/constants'; -import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerProcessEventsRoute = (router: IRouter) => { router.get( @@ -45,35 +43,25 @@ export const doSearch = async ( forward = true ) => { const search = await client.search({ - // TODO: move alerts into it's own route with it's own pagination. - index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], - ignore_unavailable: true, + index: [PROCESS_EVENTS_INDEX], body: { query: { match: { [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, }, }, - // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available - // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS - runtime_mappings: { - [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { - type: 'keyword', - }, - }, size: PROCESS_EVENTS_PER_PAGE, - sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + // we first sort by process.start, this allows lifecycle events to load all at once for a given process, and + // avoid issues like where the session leaders 'end' event is loaded at the very end of what could be multiple pages of events + sort: [ + { 'process.start': forward ? 'asc' : 'desc' }, + { '@timestamp': forward ? 'asc' : 'desc' }, + ], search_after: cursor ? [cursor] : undefined, }, }); - const events = search.hits.hits.map((hit: any) => { - // TODO: re-eval if this is needed after moving alerts to it's own route. - // the .siem-signals-default index flattens many properties. this util unflattens them. - hit._source = expandDottedObject(hit._source); - - return hit; - }); + const events = search.hits.hits; if (!forward) { events.reverse(); diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts index 0d1375081ca87..29995077ccfbe 100644 --- a/x-pack/plugins/session_view/server/types.ts +++ b/x-pack/plugins/session_view/server/types.ts @@ -4,8 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + RuleRegistryPluginSetupContract as RuleRegistryPluginSetup, + RuleRegistryPluginStartContract as RuleRegistryPluginStart, +} from '../../rule_registry/server'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewSetupPlugins {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewStartPlugins {} +export interface SessionViewSetupPlugins { + ruleRegistry: RuleRegistryPluginSetup; +} + +export interface SessionViewStartPlugins { + ruleRegistry: RuleRegistryPluginStart; +} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json index a99e83976a31d..0a21d320dfb29 100644 --- a/x-pack/plugins/session_view/tsconfig.json +++ b/x-pack/plugins/session_view/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../infra/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" } ] } From d253355234e2b1b393ec7e6dc10641edfe8f900c Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 23 Mar 2022 11:46:42 -0400 Subject: [PATCH 16/66] [SearchProfiler] Handle scenario when user has no indices (#128066) --- .../application/hooks/use_request_profile.ts | 31 ++++++++- .../apps/dev_tools/searchprofiler_editor.ts | 64 +++++++++++++++---- x-pack/test/functional/config.js | 8 +++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts index c27ca90e6e2f2..7f5d31b781310 100644 --- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts +++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts @@ -21,6 +21,16 @@ interface ReturnValue { error?: string; } +interface ProfileResponse { + profile?: { shards: ShardSerialized[] }; + _shards: { + failed: number; + skipped: number; + total: number; + successful: number; + }; +} + const extractProfilerErrorMessage = (e: any): string | undefined => { if (e.body?.attributes?.error?.reason) { const { reason, line, col } = e.body.attributes.error; @@ -67,8 +77,7 @@ export const useRequestProfile = () => { try { const resp = await http.post< - | { ok: true; resp: { profile: { shards: ShardSerialized[] } } } - | { ok: false; err: { msg: string } } + { ok: true; resp: ProfileResponse } | { ok: false; err: { msg: string } } >('../api/searchprofiler/profile', { body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, @@ -78,7 +87,23 @@ export const useRequestProfile = () => { return { data: null, error: resp.err.msg }; } - return { data: resp.resp.profile.shards }; + // If a user attempts to run Search Profiler without any indices, + // _shards=0 and a "profile" output will not be returned + if (resp.resp._shards.total === 0) { + notifications.addDanger({ + 'data-test-subj': 'noShardsNotification', + title: i18n.translate('xpack.searchProfiler.errorNoShardsTitle', { + defaultMessage: 'Unable to profile', + }), + text: i18n.translate('xpack.searchProfiler.errorNoShardsDescription', { + defaultMessage: 'Verify your index input matches a valid index', + }), + }); + + return { data: null }; + } + + return { data: resp.resp.profile!.shards }; } catch (e) { const profilerErrorMessage = extractProfilerErrorMessage(e); if (profilerErrorMessage) { diff --git a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts index 3ab27e52477a6..9a2968a1fd8b5 100644 --- a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts +++ b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { @@ -14,6 +15,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const aceEditor = getService('aceEditor'); const retry = getService('retry'); const security = getService('security'); + const es = getService('es'); + const log = getService('log'); const editorTestSubjectSelector = 'searchProfilerEditor'; @@ -34,23 +37,23 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const okInput = [ `{ -"query": { -"match_all": {}`, + "query": { + "match_all": {}`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }"""`, + "query": { + "match_all": { + "test": """{ "more": "json" }"""`, ]; const notOkInput = [ `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""`, + "query": { + "match_all": { + "test": """{ "more": "json" }""`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""'`, + "query": { + "match_all": { + "test": """{ "more": "json" }""'`, ]; const expectHasParseErrorsToBe = (expectation: boolean) => async (inputs: string[]) => { @@ -70,5 +73,44 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectHasParseErrorsToBe(false)(okInput); await expectHasParseErrorsToBe(true)(notOkInput); }); + + describe('No indices', () => { + before(async () => { + // Delete any existing indices that were not properly cleaned up + try { + const indices = await es.indices.get({ + index: '*', + }); + const indexNames = Object.keys(indices); + + if (indexNames.length > 0) { + await asyncForEach(indexNames, async (indexName) => { + await es.indices.delete({ index: indexName }); + }); + } + } catch (e) { + log.debug('[Setup error] Error deleting existing indices'); + throw e; + } + }); + + it('returns error if profile is executed with no valid indices', async () => { + const input = { + query: { + match_all: {}, + }, + }; + + await aceEditor.setValue(editorTestSubjectSelector, JSON.stringify(input)); + + await testSubjects.click('profileButton'); + + await retry.waitFor('notification renders', async () => { + const notification = await testSubjects.find('noShardsNotification'); + const notificationText = await notification.getVisibleText(); + return notificationText.includes('Unable to profile'); + }); + }); + }); }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 28000c3d4bac8..b7774b463d058 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -425,6 +425,14 @@ export default async function ({ readConfigFile }) { }, global_devtools_read: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['read', 'all'], + }, + ], + }, kibana: [ { feature: { From 09f78b01b966854d63b5cd7c79e36c9f35bbd580 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 23 Mar 2022 09:33:28 -0700 Subject: [PATCH 17/66] skip suite failing es promotion (#128396) --- x-pack/test/functional/apps/lens/show_underlying_data.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index d6ae299baceaf..2444e8714e014 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -16,7 +16,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - describe('show underlying data', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/128396 + describe.skip('show underlying data', () => { it('should show the open button for a compatible saved visualization', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); From 2f06801f8ee18b2cdd7ce2280530fe8be479eb6c Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Mar 2022 12:58:41 -0400 Subject: [PATCH 18/66] [Fleet] Fix refresh assets tab on package install (#128285) --- .../integrations/sections/epm/screens/detail/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index d002a743e77bc..dbd1c71da3d1b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -144,9 +144,13 @@ export function Detail() { // Refresh package info when status change const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus); + useEffect(() => { + if (packageInstallStatus === 'not_installed') { + setOldPackageStatus(packageInstallStatus); + } if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') { - setOldPackageStatus(oldPackageInstallStatus); + setOldPackageStatus(packageInstallStatus); refreshPackageInfo(); } }, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]); From 42e6cee204043b97eda251b5fefffdaf4008ce43 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Wed, 23 Mar 2022 19:00:00 +0100 Subject: [PATCH 19/66] [Cases] Select case modal hook hides closed and all dropdown filters by default (#128380) --- .../use_cases_add_to_existing_case_modal.test.tsx | 4 ++++ .../selector_modal/use_cases_add_to_existing_case_modal.tsx | 2 ++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index df40ccd3b1e90..b0e316e891744 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import React from 'react'; +import { CaseStatuses, StatusAll } from '../../../../common'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; @@ -62,6 +63,9 @@ describe('use cases add to existing case modal hook', () => { expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, + payload: expect.objectContaining({ + hiddenStatuses: [CaseStatuses.closed, StatusAll], + }), }) ); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 5341f5be4183d..1e65fee4565b2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -6,6 +6,7 @@ */ import { useCallback } from 'react'; +import { CaseStatuses, StatusAll } from '../../../../common'; import { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; import { Case } from '../../../containers/types'; @@ -44,6 +45,7 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: { ...props, + hiddenStatuses: [CaseStatuses.closed, StatusAll], onRowClick: (theCase?: Case) => { // when the case is undefined in the modal // the user clicked "create new case" From f49f58614f3e6fe2310f61d19a6571b49f4053a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Wed, 23 Mar 2022 19:34:03 +0100 Subject: [PATCH 20/66] [App Search] Fix sorting options for elasticsearch index based engines (#128384) * Fix sorting options for elasticsearch index based engines * review changes and missing translation changes --- .../build_search_ui_config.ts | 12 +++--- .../search_experience/search_experience.tsx | 40 +++++++++++++++---- .../app_search/components/engine/types.ts | 1 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts index 25342f24cc872..9c06527162b81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts @@ -9,7 +9,12 @@ import { Schema } from '../../../../shared/schema/types'; import { Fields } from './types'; -export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields: Fields) => { +export const buildSearchUIConfig = ( + apiConnector: object, + schema: Schema, + fields: Fields, + initialState = { sortDirection: 'desc', sortField: 'id' } +) => { const facets = fields.filterFields.reduce( (facetsConfig, fieldName) => ({ ...facetsConfig, @@ -22,10 +27,7 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields alwaysSearchOnInitialLoad: true, apiConnector, trackUrlState: false, - initialState: { - sortDirection: 'desc', - sortField: 'id', - }, + initialState, searchQuery: { disjunctiveFacets: fields.filterFields, facets, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index ed2a1ed54f06d..52e0acbc81520 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -31,25 +31,39 @@ import { SearchExperienceContent } from './search_experience_content'; import { Fields, SortOption } from './types'; import { SearchBoxView, SortingView, MultiCheckboxFacetsView } from './views'; -const RECENTLY_UPLOADED = i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded', +const DOCUMENT_ID = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.documentId', { - defaultMessage: 'Recently Uploaded', + defaultMessage: 'Document ID', } ); + +const RELEVANCE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.relevance', + { defaultMessage: 'Relevance' } +); + const DEFAULT_SORT_OPTIONS: SortOption[] = [ { - name: DESCENDING(RECENTLY_UPLOADED), + name: DESCENDING(DOCUMENT_ID), value: 'id', direction: 'desc', }, { - name: ASCENDING(RECENTLY_UPLOADED), + name: ASCENDING(DOCUMENT_ID), value: 'id', direction: 'asc', }, ]; +const RELEVANCE_SORT_OPTIONS: SortOption[] = [ + { + name: RELEVANCE, + value: '_score', + direction: 'desc', + }, +]; + export const SearchExperience: React.FC = () => { const { engine } = useValues(EngineLogic); const { http } = useValues(HttpLogic); @@ -66,8 +80,10 @@ export const SearchExperience: React.FC = () => { sortFields: [], } ); + const sortOptions = + engine.type === 'elasticsearch' ? RELEVANCE_SORT_OPTIONS : DEFAULT_SORT_OPTIONS; - const sortingOptions = buildSortOptions(fields, DEFAULT_SORT_OPTIONS); + const sortingOptions = buildSortOptions(fields, sortOptions); const connector = new AppSearchAPIConnector({ cacheResponses: false, @@ -78,7 +94,17 @@ export const SearchExperience: React.FC = () => { }, }); - const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields); + const initialState = { + sortField: engine.type === 'elasticsearch' ? '_score' : 'id', + sortDirection: 'desc', + }; + + const searchProviderConfig = buildSearchUIConfig( + connector, + engine.schema || {}, + fields, + initialState + ); return (
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts index 6faa749f95864..acdeed4854ecd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts @@ -12,6 +12,7 @@ export enum EngineTypes { default = 'default', indexed = 'indexed', meta = 'meta', + elasticsearch = 'elasticsearch', } export interface Engine { name: string; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8914efcf12ded..db10095ce0591 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -8729,7 +8729,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "Trier les résultats par", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName} (croiss.)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName} (décroiss.)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "Récemment chargé", "xpack.enterpriseSearch.appSearch.documents.title": "Documents", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "Les éditeurs peuvent gérer les paramètres de recherche.", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "Créer un moteur", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 48f0d74d73765..f1ab772dbb243 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10279,7 +10279,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "結果の並べ替え条件", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(昇順)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降順)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近アップロードされたドキュメント", "xpack.enterpriseSearch.appSearch.documents.title": "ドキュメント", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "エディターは検索設定を管理できます。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "エンジンを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bbc00d8d205f7..51c4915baab29 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10300,7 +10300,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "结果排序方式", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(升序)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降序)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近上传", "xpack.enterpriseSearch.appSearch.documents.title": "文档", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "编辑人员可以管理搜索设置。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "创建引擎", From 98300c236404d0378caf26de08b0866877f997b2 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Wed, 23 Mar 2022 19:39:38 +0100 Subject: [PATCH 21/66] [Security Solution][Endpoint] Accept all kinds of filenames (without wildcard) in wildcard-ed event filter `file.path.text` (#127432) * update filename regex to include multiple hyphens and periods Uses a much simpler pattern that covers a whole gamut file name patterns. fixes elastic/security-team/issues/3294 * remove duplicated code * add tests for `process.name` entry for filenames with wildcard path refs elastic/kibana/pull/120349 elastic/kibana/pull/125202 * Add file.name optimized entry when wildcard filepath in file.path.text has a filename fixes elastic/security-team/issues/3294 * update regex to include unicode chars review changes * add tests for `file.name` and `process.name` entries if it already exists This works out of the box and we don't add endpoint related `file.name` or `process.name` entry when it already is added by the user refs elastic/kibana/pull/127958#discussion_r829086447 elastic/security-team/issues/3199 * fix `file.name` and `file.path.text` entries for linux and mac/linux refs elastic/kibana/pull/127098 * do not add endpoint optimized entry Add `file.name` and `process.name` entry for wildcard path values only when file.name and process.name entries do not already exist. The earlier commit 8a516ae9c0580eb44b57666e7a5934c543c3e4bb was mistakenly labeled as this worked out of the box. In the same commit we notice that the test data had a wildcard file path that did not add a `file.name` or `process.name` entry. For more see: elastic/kibana/pull/127958#discussion_r829086447 elastic/security-team/issues/3199 * update regex to include gamut of unicode characters review suggestions * remove regex altogether simplifies the logic to check if path is without wildcard characters. This way it includes all other strings as valid filenames that do not have * or ? * update artifact creation for `file.path.text` entries Similar to when we normalize `file.path.caseless` entries, except that the `type` is `*_cased` for linux and `*_caseless` for non-linux --- .../src/path_validations/index.test.ts | 89 ++- .../src/path_validations/index.ts | 25 +- .../endpoint/lib/artifacts/lists.test.ts | 616 ++++++++++++++++++ .../server/endpoint/lib/artifacts/lists.ts | 50 +- .../manifest_manager/manifest_manager.ts | 109 ++-- 5 files changed, 790 insertions(+), 99 deletions(-) diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index ee2d8764a30af..5bb84816b1602 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -20,10 +20,31 @@ describe('validateFilePathInput', () => { describe('windows', () => { const os = OperatingSystem.WINDOWS; + it('does not warn on valid filenames', () => { + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-1231205124.gz', + }) + ).not.toBeDefined(); + expect( + validateFilePathInput({ + os, + value: "C:\\Windows\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(undefined); + }); + it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: 'c:\\path*.exe' })).toEqual( FILENAME_WILDCARD_WARNING ); + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-*.gz', + }) + ).toEqual(FILENAME_WILDCARD_WARNING); }); it('warns on unix paths or non-windows paths', () => { @@ -34,6 +55,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'c:\\path/opt' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'c:\\folder\\' })).toEqual(FILEPATH_WARNING); }); }); describe('unix paths', () => { @@ -42,8 +64,22 @@ describe('validateFilePathInput', () => { ? OperatingSystem.MAC : OperatingSystem.LINUX; + it('does not warn on valid filenames', () => { + expect(validateFilePathInput({ os, value: '/opt/*/FILENAME.EXE-1231205124.gz' })).not.toEqual( + FILENAME_WILDCARD_WARNING + ); + expect( + validateFilePathInput({ + os, + value: "/opt/*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).not.toEqual(FILENAME_WILDCARD_WARNING); + }); it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: '/opt/bin*' })).toEqual(FILENAME_WILDCARD_WARNING); + expect(validateFilePathInput({ os, value: '/opt/FILENAME.EXE-*.gz' })).toEqual( + FILENAME_WILDCARD_WARNING + ); }); it('warns on windows paths', () => { @@ -54,6 +90,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'opt/bin\\file.exe' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '/folder/' })).toEqual(FILEPATH_WARNING); }); }); }); @@ -577,50 +614,82 @@ describe('Unacceptable Mac/Linux exact paths', () => { }); }); -describe('Executable filenames with wildcard PATHS', () => { +describe('hasSimpleExecutableName', () => { it('should return TRUE when MAC/LINUX wildcard paths have an executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/opt/*/app', }) ).toEqual(true); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/**/app.dmg', }) ).toEqual(true); - }); - - it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { expect( hasSimpleExecutableName({ - os: OperatingSystem.WINDOWS, + os, type: 'wildcard', - value: 'c:\\**\\path.exe', + value: "/sy*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", }) ).toEqual(true); }); it('should return FALSE when MAC/LINUX wildcard paths have a wildcard in executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/op/*/*pp', }) ).toEqual(false); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/b**/ap.m**', }) ).toEqual(false); }); + + it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'c:\\**\\path.exe', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'C:\\*\\file-name.path华语 1234.txt', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: "C:\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(true); + }); + it('should return FALSE when WINDOWS wildcards paths have a wildcard in executable name', () => { expect( hasSimpleExecutableName({ diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index 97a726703feef..b64cb4cf6a052 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -31,20 +31,6 @@ export const enum OperatingSystem { export type EntryTypes = 'match' | 'wildcard' | 'match_any'; export type TrustedAppEntryTypes = Extract; -/* - * regex to match executable names - * starts matching from the eol of the path - * file names with a single or multiple spaces (for spaced names) - * and hyphens and combinations of these that produce complex names - * such as: - * c:\home\lib\dmp.dmp - * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp - * /home/lib/dmp.dmp - * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp - */ -export const WIN_EXEC_PATH = /(\\[-\w]+|\\[-\w]+[\.]+[\w]+)$/i; -export const UNIX_EXEC_PATH = /(\/[-\w]+|\/[-\w]+[\.]+[\w]+)$/i; - export const validateFilePathInput = ({ os, value = '', @@ -70,7 +56,7 @@ export const validateFilePathInput = ({ } if (isValidFilePath) { - if (!hasSimpleFileName) { + if (hasSimpleFileName !== undefined && !hasSimpleFileName) { return FILENAME_WILDCARD_WARNING; } } else { @@ -86,9 +72,14 @@ export const hasSimpleExecutableName = ({ os: OperatingSystem; type: EntryTypes; value: string; -}): boolean => { +}): boolean | undefined => { + const separator = os === OperatingSystem.WINDOWS ? '\\' : '/'; + const lastString = value.split(separator).pop(); + if (!lastString) { + return; + } if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); + return (lastString.split('*').length || lastString.split('?').length) === 1; } return true; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 83dbcf1ca6f6d..179ea3827df0c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -513,6 +513,622 @@ describe('artifacts lists', () => { }); }); + describe('Endpoint Artifacts', () => { + const getOsFilter = (os: 'macos' | 'linux' | 'windows') => + `exception-list-agnostic.attributes.os_types:"${os} "`; + + describe('linux', () => { + test('it should add process.name entry when wildcard process.executable entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + + describe('macos/windows', () => { + test('it should add process.name entry for process.executable entry with wildcard type', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + }); + const TEST_EXCEPTION_LIST_ITEM = { entries: [ { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 7521ccbf9df91..2ea52485e625b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -187,11 +187,16 @@ function getMatcherFunction({ matchAny?: boolean; os: ExceptionListItemSchema['os_types'][number]; }): TranslatedEntryMatcher { + const doesFieldEndWith: boolean = + field.endsWith('.caseless') || field.endsWith('.name') || field.endsWith('.text'); + return matchAny - ? field.endsWith('.caseless') && os !== 'linux' - ? 'exact_caseless_any' + ? doesFieldEndWith + ? os === 'linux' + ? 'exact_cased_any' + : 'exact_caseless_any' : 'exact_cased_any' - : field.endsWith('.caseless') + : doesFieldEndWith ? os === 'linux' ? 'exact_cased' : 'exact_caseless' @@ -213,7 +218,9 @@ function getMatcherWildcardFunction({ } function normalizeFieldName(field: string): string { - return field.endsWith('.caseless') ? field.substring(0, field.lastIndexOf('.')) : field; + return field.endsWith('.caseless') || field.endsWith('.text') + ? field.substring(0, field.lastIndexOf('.')) + : field; } function translateItem( @@ -223,7 +230,7 @@ function translateItem( const itemSet = new Set(); const getEntries = (): TranslatedExceptionListItem['entries'] => { return item.entries.reduce((translatedEntries, entry) => { - const translatedEntry = translateEntry(schemaVersion, entry, item.os_types[0]); + const translatedEntry = translateEntry(schemaVersion, item.entries, entry, item.os_types[0]); if (translatedEntry !== undefined) { if (translatedEntryType.is(translatedEntry)) { @@ -256,12 +263,11 @@ function translateItem( }; } -function appendProcessNameEntry({ - wildcardProcessEntry, +function appendOptimizedEntryForEndpoint({ entry, os, + wildcardProcessEntry, }: { - wildcardProcessEntry: TranslatedEntryMatchWildcard; entry: { field: string; operator: 'excluded' | 'included'; @@ -269,11 +275,15 @@ function appendProcessNameEntry({ value: string; }; os: ExceptionListItemSchema['os_types'][number]; + wildcardProcessEntry: TranslatedEntryMatchWildcard; }): TranslatedPerformantEntries { const entries: TranslatedPerformantEntries = [ wildcardProcessEntry, { - field: normalizeFieldName('process.name'), + field: + entry.field === 'file.path.text' + ? normalizeFieldName('file.name') + : normalizeFieldName('process.name'), operator: entry.operator, type: (os === 'linux' ? 'exact_cased' : 'exact_caseless') as Extract< TranslatedEntryMatcher, @@ -291,6 +301,7 @@ function appendProcessNameEntry({ function translateEntry( schemaVersion: string, + exceptionListItemEntries: ExceptionListItemSchema['entries'], entry: Entry | EntryNested, os: ExceptionListItemSchema['os_types'][number] ): TranslatedEntry | TranslatedPerformantEntries | undefined { @@ -298,7 +309,12 @@ function translateEntry( case 'nested': { const nestedEntries = entry.entries.reduce( (entries, nestedEntry) => { - const translatedEntry = translateEntry(schemaVersion, nestedEntry, os); + const translatedEntry = translateEntry( + schemaVersion, + exceptionListItemEntries, + nestedEntry, + os + ); if (nestedEntry !== undefined && translatedEntryNestedEntry.is(translatedEntry)) { entries.push(translatedEntry); } @@ -354,11 +370,21 @@ function translateEntry( type: entry.type, value: entry.value, }); - if (hasExecutableName) { + + const existingFields = exceptionListItemEntries.map((e) => e.field); + const doAddPerformantEntries = !( + existingFields.includes('process.name') || existingFields.includes('file.name') + ); + + if (hasExecutableName && doAddPerformantEntries) { // when path has a full executable name // append a process.name entry based on os // `exact_cased` for linux and `exact_caseless` for others - return appendProcessNameEntry({ entry, os, wildcardProcessEntry }); + return appendOptimizedEntryForEndpoint({ + entry, + os, + wildcardProcessEntry, + }); } else { return wildcardProcessEntry; } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 7be2a36396a71..a8c63bbb88e13 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -31,6 +31,7 @@ import { getArtifactId, getEndpointExceptionList, Manifest, + ArtifactListId, } from '../../../lib/artifacts'; import { InternalArtifactCompleteSchema, @@ -48,6 +49,11 @@ interface ArtifactsBuildResult { policySpecificArtifacts: Record; } +interface BuildArtifactsForOsOptions { + listId: ArtifactListId; + name: string; +} + const iterateArtifactsBuildResult = async ( result: ArtifactsBuildResult, callback: (artifact: InternalArtifactCompleteSchema, policyId?: string) => Promise @@ -174,20 +180,29 @@ export class ManifestManager { /** * Builds an artifact (one per supported OS) based on the current state of the - * Trusted Apps list (which uses the `exception-list-agnostic` SO type) + * artifacts list (Trusted Apps, Host Iso. Exceptions, Event Filters, Blocklists) + * (which uses the `exception-list-agnostic` SO type) */ - protected async buildTrustedAppsArtifact(os: string, policyId?: string) { + protected async buildArtifactsForOs({ + listId, + name, + os, + policyId, + }: { + os: string; + policyId?: string; + } & BuildArtifactsForOsOptions): Promise { return buildArtifact( await getEndpointExceptionList({ elClient: this.exceptionListClient, schemaVersion: this.schemaVersion, os, policyId, - listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + listId, }), this.schemaVersion, os, - ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME + name ); } @@ -198,9 +213,13 @@ export class ManifestManager { protected async buildTrustedAppsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + name: ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildTrustedAppsArtifact(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -208,7 +227,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildTrustedAppsArtifact(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -224,9 +245,13 @@ export class ManifestManager { protected async buildEventFiltersArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildEventFiltersForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -234,7 +259,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildEventFiltersForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -242,21 +269,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildEventFiltersForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME - ); - } - /** * Builds an array of Blocklist entries (one per supported OS) based on the current state of the * Blocklist list @@ -265,9 +277,13 @@ export class ManifestManager { protected async buildBlocklistArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + name: ArtifactConstants.GLOBAL_BLOCKLISTS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildBlocklistForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -275,7 +291,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildBlocklistForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -283,21 +301,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildBlocklistForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_BLOCKLISTS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_BLOCKLISTS_NAME - ); - } - /** * Builds an array of endpoint host isolation exception (one per supported OS) based on the current state of the * Host Isolation Exception List @@ -307,9 +310,13 @@ export class ManifestManager { protected async buildHostIsolationExceptionsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildHostIsolationExceptionForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -318,7 +325,7 @@ export class ManifestManager { for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; policySpecificArtifacts[policyId].push( - await this.buildHostIsolationExceptionForOs(os, policyId) + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) ); } } @@ -327,24 +334,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildHostIsolationExceptionForOs( - os: string, - policyId?: string - ): Promise { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME - ); - } - /** * Writes new artifact SO. * From 5e73ef53277aae2da5e94b10d1fe6138a4721db1 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Mar 2022 12:41:27 -0600 Subject: [PATCH 22/66] [Security Solution] Collapse KPI and Table queries on Explore pages (#127930) --- .../__snapshots__/index.test.tsx.snap | 28 +- .../components/header_section/index.test.tsx | 90 +++++ .../components/header_section/index.tsx | 133 ++++--- .../matrix_histogram/index.test.tsx | 89 ++++- .../components/matrix_histogram/index.tsx | 45 ++- .../matrix_histogram/matrix_loader.tsx | 2 +- .../ml/anomaly/use_anomalies_table_data.ts | 4 +- .../ml/tables/anomalies_host_table.test.tsx | 88 +++++ .../ml/tables/anomalies_host_table.tsx | 40 +- .../tables/anomalies_network_table.test.tsx | 90 +++++ .../ml/tables/anomalies_network_table.tsx | 41 +- .../ml/tables/anomalies_user_table.test.tsx | 89 +++++ .../ml/tables/anomalies_user_table.tsx | 39 +- .../__snapshots__/index.test.tsx.snap | 3 + .../components/paginated_table/index.test.tsx | 365 ++++-------------- .../components/paginated_table/index.tsx | 113 +++--- .../components/stat_items/index.test.tsx | 198 ++++++---- .../common/components/stat_items/index.tsx | 262 +++++++------ .../containers/matrix_histogram/index.test.ts | 13 +- .../containers/matrix_histogram/index.ts | 8 + .../containers/query_toggle/index.test.tsx | 74 ++++ .../common/containers/query_toggle/index.tsx | 55 +++ .../containers/query_toggle/translations.tsx | 17 + .../use_search_strategy/index.test.ts | 17 +- .../containers/use_search_strategy/index.tsx | 8 + .../alerts_count_panel/index.test.tsx | 42 ++ .../alerts_kpis/alerts_count_panel/index.tsx | 37 +- .../alerts_histogram_panel/index.test.tsx | 46 +++ .../alerts_histogram_panel/index.tsx | 53 ++- .../alerts_kpis/common/components.tsx | 14 +- .../alerts/use_query.test.tsx | 18 + .../detection_engine/alerts/use_query.tsx | 6 + .../__snapshots__/index.test.tsx.snap | 1 + .../authentications_table/index.test.tsx | 1 + .../authentications_table/index.tsx | 3 + .../host_risk_score_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 1 + .../components/hosts_table/index.test.tsx | 4 + .../hosts/components/hosts_table/index.tsx | 3 + .../kpi_hosts/authentications/index.test.tsx | 66 ++++ .../kpi_hosts/authentications/index.tsx | 13 +- .../components/kpi_hosts/common/index.tsx | 21 +- .../components/kpi_hosts/hosts/index.test.tsx | 66 ++++ .../components/kpi_hosts/hosts/index.tsx | 13 +- .../kpi_hosts/risky_hosts/index.tsx | 9 +- .../kpi_hosts/unique_ips/index.test.tsx | 66 ++++ .../components/kpi_hosts/unique_ips/index.tsx | 13 +- .../index.test.tsx | 96 ++++- .../top_host_score_contributors/index.tsx | 62 ++- .../__snapshots__/index.test.tsx.snap | 1 + .../uncommon_process_table/index.test.tsx | 121 ++---- .../uncommon_process_table/index.tsx | 3 + .../containers/authentications/index.test.tsx | 30 ++ .../containers/authentications/index.tsx | 10 +- .../hosts/containers/hosts/index.test.tsx | 30 ++ .../public/hosts/containers/hosts/index.tsx | 8 + .../kpi_hosts/authentications/index.test.tsx | 28 ++ .../kpi_hosts/authentications/index.tsx | 10 +- .../containers/kpi_hosts/hosts/index.test.tsx | 28 ++ .../containers/kpi_hosts/hosts/index.tsx | 10 +- .../hosts/containers/kpi_hosts/index.tsx | 10 - .../kpi_hosts/unique_ips/index.test.tsx | 28 ++ .../containers/kpi_hosts/unique_ips/index.tsx | 10 +- .../uncommon_processes/index.test.tsx | 30 ++ .../containers/uncommon_processes/index.tsx | 10 +- .../authentications_query_tab_body.test.tsx | 68 ++++ .../authentications_query_tab_body.tsx | 11 +- .../host_risk_score_tab_body.test.tsx | 81 ++++ .../navigation/host_risk_score_tab_body.tsx | 13 +- .../navigation/hosts_query_tab_body.test.tsx | 68 ++++ .../pages/navigation/hosts_query_tab_body.tsx | 21 +- .../uncommon_process_query_tab_body.test.tsx | 68 ++++ .../uncommon_process_query_tab_body.tsx | 13 +- .../__snapshots__/embeddable.test.tsx.snap | 1 + .../components/embeddables/embeddable.tsx | 2 +- .../embeddables/embedded_map.test.tsx | 10 +- .../components/embeddables/embedded_map.tsx | 39 +- .../components/kpi_network/dns/index.test.tsx | 66 ++++ .../components/kpi_network/dns/index.tsx | 13 +- .../network/components/kpi_network/mock.ts | 2 + .../kpi_network/network_events/index.test.tsx | 66 ++++ .../kpi_network/network_events/index.tsx | 14 +- .../kpi_network/tls_handshakes/index.test.tsx | 66 ++++ .../kpi_network/tls_handshakes/index.tsx | 13 +- .../kpi_network/unique_flows/index.test.tsx | 66 ++++ .../kpi_network/unique_flows/index.tsx | 13 +- .../unique_private_ips/index.test.tsx | 66 ++++ .../kpi_network/unique_private_ips/index.tsx | 16 +- .../__snapshots__/index.test.tsx.snap | 1 + .../network_dns_table/index.test.tsx | 37 +- .../components/network_dns_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 1 + .../network_http_table/index.test.tsx | 36 +- .../components/network_http_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 2 + .../index.test.tsx | 72 +--- .../network_top_countries_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 2 + .../network_top_n_flow_table/index.test.tsx | 52 +-- .../network_top_n_flow_table/index.tsx | 3 + .../components/tls_table/index.test.tsx | 37 +- .../network/components/tls_table/index.tsx | 3 + .../components/users_table/index.test.tsx | 40 +- .../network/components/users_table/index.tsx | 3 + .../containers/kpi_network/dns/index.test.tsx | 28 ++ .../containers/kpi_network/dns/index.tsx | 10 +- .../network/containers/kpi_network/index.tsx | 12 - .../kpi_network/network_events/index.test.tsx | 28 ++ .../kpi_network/network_events/index.tsx | 10 +- .../kpi_network/tls_handshakes/index.test.tsx | 28 ++ .../kpi_network/tls_handshakes/index.tsx | 10 +- .../kpi_network/unique_flows/index.test.tsx | 28 ++ .../kpi_network/unique_flows/index.tsx | 11 +- .../unique_private_ips/index.test.tsx | 28 ++ .../kpi_network/unique_private_ips/index.tsx | 10 +- .../containers/network_dns/index.test.tsx | 31 ++ .../network/containers/network_dns/index.tsx | 10 +- .../containers/network_http/index.test.tsx | 31 ++ .../network/containers/network_http/index.tsx | 14 +- .../network_top_countries/index.test.tsx | 33 ++ .../network_top_countries/index.tsx | 10 +- .../network_top_n_flow/index.test.tsx | 33 ++ .../containers/network_top_n_flow/index.tsx | 10 +- .../network/containers/tls/index.test.tsx | 34 ++ .../public/network/containers/tls/index.tsx | 10 +- .../network/containers/users/index.test.tsx | 34 ++ .../public/network/containers/users/index.tsx | 10 +- .../details/network_http_query_table.tsx | 13 +- .../network_top_countries_query_table.tsx | 13 +- .../network_top_n_flow_query_table.tsx | 13 +- .../network/pages/details/tls_query_table.tsx | 13 +- .../pages/details/users_query_table.tsx | 13 +- .../navigation/countries_query_tab_body.tsx | 13 +- .../pages/navigation/dns_query_tab_body.tsx | 13 +- .../pages/navigation/http_query_tab_body.tsx | 13 +- .../pages/navigation/ips_query_tab_body.tsx | 13 +- .../pages/navigation/tls_query_tab_body.tsx | 13 +- .../components/overview_host/index.test.tsx | 29 +- .../components/overview_host/index.tsx | 43 ++- .../overview_network/index.test.tsx | 29 +- .../components/overview_network/index.tsx | 43 ++- .../containers/overview_host/index.test.tsx | 28 ++ .../containers/overview_host/index.tsx | 7 + .../overview_network/index.test.tsx | 28 ++ .../containers/overview_network/index.tsx | 7 + .../risk_score/containers/all/index.tsx | 8 + .../kpi_users/total_users/index.test.tsx | 68 ++++ .../kpi_users/total_users/index.tsx | 14 +- .../user_risk_score_table/index.test.tsx | 5 +- .../user_risk_score_table/index.tsx | 3 + .../all_users_query_tab_body.test.tsx | 68 ++++ .../navigation/all_users_query_tab_body.tsx | 13 +- .../user_risk_score_tab_body.test.tsx | 81 ++++ .../navigation/user_risk_score_tab_body.tsx | 13 +- 154 files changed, 3965 insertions(+), 1144 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 6701224289e66..45a6e20cf087d 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -18,19 +18,25 @@ exports[`HeaderSection it renders 1`] = ` responsive={false} > - -

- + - Test title - -

-
+

+ + Test title + +

+ +
+ diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index 5ec97ea59bc1d..2296dc78241f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -180,4 +180,94 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); }); + + test('it does not render query-toggle-header when no arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(false); + }); + + test('it does render query-toggle-header when toggleQuery arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(true); + }); + + test('it does render everything but title when toggleStatus = true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowDown' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + true + ); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + test('it does not render anything but title when toggleStatus = false', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowRight' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + false + ); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); + + test('it toggles query when icon is clicked', () => { + const mockToggle = jest.fn(); + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockToggle).toBeCalledWith(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index ae07a03ba6407..7997dfa83e27b 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui'; -import React from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiTitle, + EuiTitleSize, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; import { InspectButton } from '../inspect'; import { Subtitle } from '../subtitle'; +import * as i18n from '../../containers/query_toggle/translations'; interface HeaderProps { border?: boolean; @@ -51,6 +59,8 @@ export interface HeaderSectionProps extends HeaderProps { split?: boolean; stackHeader?: boolean; subtitle?: string | React.ReactNode; + toggleQuery?: (status: boolean) => void; + toggleStatus?: boolean; title: string | React.ReactNode; titleSize?: EuiTitleSize; tooltip?: string; @@ -72,56 +82,87 @@ const HeaderSectionComponent: React.FC = ({ subtitle, title, titleSize = 'm', + toggleQuery, + toggleStatus = true, tooltip, -}) => ( -
- - - - - -

- {title} - {tooltip && ( - <> - {' '} - - +}) => { + const toggle = useCallback(() => { + if (toggleQuery) { + toggleQuery(!toggleStatus); + } + }, [toggleQuery, toggleStatus]); + return ( +
+ + + + + + {toggleQuery && ( + + + )} -

-
+ + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
+
+
- {!hideSubtitle && ( - - )} -
- - {id && showInspectButton && ( - - + {!hideSubtitle && toggleStatus && ( + + )} - )} - {headerFilters && {headerFilters}} -
- + {id && showInspectButton && toggleStatus && ( + + + + )} - {children && ( - - {children} + {headerFilters && toggleStatus && ( + + {headerFilters} + + )} + - )} - -
-); + + {children && toggleStatus && ( + + {children} + + )} + + + ); +}; export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index aee49bd1b00ae..1de9e08b4c65c 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -15,6 +15,9 @@ import { TestProviders } from '../../mock'; import { mockRuntimeMappings } from '../../containers/source/mock'; import { dnsTopDomainsLensAttributes } from '../visualization_actions/lens_attributes/network/dns_top_domains'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; + +jest.mock('../../containers/query_toggle'); jest.mock('../../lib/kibana'); jest.mock('./matrix_loader', () => ({ @@ -25,9 +28,7 @@ jest.mock('../charts/barchart', () => ({ BarChart: () =>
, })); -jest.mock('../../containers/matrix_histogram', () => ({ - useMatrixHistogramCombined: jest.fn(), -})); +jest.mock('../../containers/matrix_histogram'); jest.mock('../visualization_actions', () => ({ VisualizationActions: jest.fn(({ className }: { className: string }) => ( @@ -78,9 +79,13 @@ describe('Matrix Histogram Component', () => { title: 'mockTitle', runtimeMappings: mockRuntimeMappings, }; - - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + const mockUseMatrix = useMatrixHistogramCombined as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseMatrix.mockReturnValue([ false, { data: null, @@ -88,14 +93,16 @@ describe('Matrix Histogram Component', () => { totalCount: null, }, ]); - wrapper = mount(, { - wrappingComponent: TestProviders, - }); }); describe('on initial load', () => { + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + }); test('it requests Matrix Histogram', () => { - expect(useMatrixHistogramCombined).toHaveBeenCalledWith({ + expect(mockUseMatrix).toHaveBeenCalledWith({ endDate: mockMatrixOverTimeHistogramProps.endDate, errorMessage: mockMatrixOverTimeHistogramProps.errorMessage, histogramType: mockMatrixOverTimeHistogramProps.histogramType, @@ -114,6 +121,9 @@ describe('Matrix Histogram Component', () => { describe('spacer', () => { test('it renders a spacer by default', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(true); }); @@ -129,8 +139,11 @@ describe('Matrix Histogram Component', () => { }); describe('not initial load', () => { - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + mockUseMatrix.mockReturnValue([ false, { data: [ @@ -159,6 +172,9 @@ describe('Matrix Histogram Component', () => { describe('select dropdown', () => { test('should be hidden if only one option is provided', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('EuiSelect').exists()).toBe(false); }); }); @@ -287,4 +303,53 @@ describe('Matrix Histogram Component', () => { expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); }); }); + + describe('toggle query', () => { + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + + test('toggleQuery updates toggleStatus', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseMatrix.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index dbf525f8e14cb..488948de074f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -34,6 +34,7 @@ import { GetLensAttributes, LensAttributes } from '../visualization_actions/type import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; import { APP_ID, SecurityPageName } from '../../../../common/constants'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; export type MatrixHistogramComponentProps = MatrixHistogramProps & Omit & { @@ -148,6 +149,19 @@ export const MatrixHistogramComponent: React.FC = }, [defaultStackByOption, stackByOptions] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const matrixHistogramRequest = { endDate, @@ -161,9 +175,8 @@ export const MatrixHistogramComponent: React.FC = runtimeMappings, isPtrIncluded, docValueFields, - skip, + skip: querySkip, }; - const [loading, { data, inspect, totalCount, refetch }] = useMatrixHistogramCombined(matrixHistogramRequest); const [{ pageName }] = useRouteSpy(); @@ -225,7 +238,7 @@ export const MatrixHistogramComponent: React.FC = > {loading && !isInitialLoading && ( @@ -239,8 +252,11 @@ export const MatrixHistogramComponent: React.FC = = {headerChildren} - - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} {showSpacer && } diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx index efa4ba4c6eb0f..8eca508a4b74b 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` - flex 1; + flex: 1; `; const MatrixLoaderComponent = () => ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts index f1cab9c2f441d..58610298d4395 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts @@ -80,7 +80,9 @@ export const useAnomaliesTableData = ({ earliestMs: number, latestMs: number ) { - if (isMlUser && !skip && jobIds.length > 0) { + if (skip) { + setLoading(false); + } else if (isMlUser && !skip && jobIds.length > 0) { try { const data = await anomaliesTableData( { diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx new file mode 100644 index 0000000000000..7701880bd7b2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { AnomaliesHostTable } from './anomalies_host_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { HostsType } from '../../../../hosts/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies host table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + skip: false, + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx index 318f452e0c1df..eec90e6117c28 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -21,6 +21,7 @@ import { BasicTable } from './basic_table'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,24 @@ const AnomaliesHostTableComponent: React.FC = ({ type, }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesHostTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromHostType(type, hostName), filterQuery: { exists: { field: 'host.name' }, @@ -64,21 +79,26 @@ const AnomaliesHostTableComponent: React.FC = ({ return ( - - type is not as specific as EUI's... - columns={columns} - items={hosts} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={hosts} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx new file mode 100644 index 0000000000000..b7491562a5d72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { AnomaliesNetworkTable } from './anomalies_network_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { NetworkType } from '../../../../network/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { FlowTarget } from '../../../../../common/search_strategy'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies network table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + flowTarget: FlowTarget.destination, + narrowDateRange: jest.fn(), + skip: false, + type: NetworkType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx index 78795c6d3614a..242114a806ca8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -20,6 +20,7 @@ import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; import { Panel } from '../../panel'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,25 @@ const AnomaliesNetworkTableComponent: React.FC = ({ flowTarget, }) => { const capabilities = useMlCapabilities(); + + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesNetwork-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromNetworkType(type, ip, flowTarget), }); @@ -63,18 +79,23 @@ const AnomaliesNetworkTableComponent: React.FC = ({ subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT( pagination.totalItemCount )}`} + height={!toggleStatus ? 40 : undefined} title={i18n.ANOMALIES} tooltip={i18n.TOOLTIP} + toggleQuery={toggleQuery} + toggleStatus={toggleStatus} isInspectDisabled={skip} /> - - type is not as specific as EUI's... - columns={columns} - items={networks} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={networks} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx new file mode 100644 index 0000000000000..40aab638b854a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { AnomaliesUserTable } from './anomalies_user_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { UsersType } from '../../../../users/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies user table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + userName: 'coolguy', + skip: false, + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx index 061f2c04cef6d..c67455c0772b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -23,6 +23,7 @@ import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; import { convertAnomaliesToUsers } from './convert_anomalies_to_users'; import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -40,10 +41,24 @@ const AnomaliesUserTableComponent: React.FC = ({ }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesUserTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromUsersType(type, userName), filterQuery: { exists: { field: 'user.name' }, @@ -67,21 +82,27 @@ const AnomaliesUserTableComponent: React.FC = ({ return ( - type is not as specific as EUI's... - columns={columns} - items={users} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={users} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index a2fffc32be46d..bf03d637e8811 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta

@@ -58,6 +60,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, ] } + data-test-subj="paginated-basic-table" items={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx index 0c09dce9c07cb..57686126dfb10 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx @@ -15,6 +15,8 @@ import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; import { ThemeProvider } from 'styled-components'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; import { Direction } from '../../../../common/search_strategy'; +import { useQueryToggle } from '../../containers/query_toggle'; +jest.mock('../../containers/query_toggle'); jest.mock('react', () => { const r = jest.requireActual('react'); @@ -36,37 +38,41 @@ const mockTheme = getMockTheme({ }); describe('Paginated Table Component', () => { - let loadPage: jest.Mock; - let updateLimitPagination: jest.Mock; - let updateActivePage: jest.Mock; + const loadPage = jest.fn(); + const updateLimitPagination = jest.fn(); + const updateActivePage = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + const mockSetQuerySkip = jest.fn(); + beforeEach(() => { - loadPage = jest.fn(); - updateLimitPagination = jest.fn(); - updateActivePage = jest.fn(); + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); }); + const testProps = { + activePage: 0, + columns: getHostsColumns(), + headerCount: 1, + headerSupplement:

{'My test supplement.'}

, + headerTitle: 'Hosts', + headerTooltip: 'My test tooltip', + headerUnit: 'Test Unit', + itemsPerRow: rowItems, + limit: 1, + loading: false, + loadPage, + pageOfItems: mockData.Hosts.edges, + setQuerySkip: jest.fn(), + showMorePagesIndicator: true, + totalCount: 10, + updateActivePage, + updateLimitPagination: (limit: number) => updateLimitPagination({ limit }), + }; + describe('rendering', () => { test('it renders the default load more table', () => { - const wrapper = shallow( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -74,24 +80,7 @@ describe('Paginated Table Component', () => { test('it renders the loading panel at the beginning ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={[]} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -103,24 +92,7 @@ describe('Paginated Table Component', () => { test('it renders the over loading panel after data has been in the table ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -130,24 +102,7 @@ describe('Paginated Table Component', () => { test('it renders the correct amount of pages and starts at activePage: 0', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -167,24 +122,7 @@ describe('Paginated Table Component', () => { test('it render popover to select new limit in table', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -195,24 +133,7 @@ describe('Paginated Table Component', () => { test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -224,24 +145,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -253,22 +161,9 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} + {...testProps} limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} totalCount={DEFAULT_MAX_TABLE_QUERY_SIZE * 3} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -279,24 +174,7 @@ describe('Paginated Table Component', () => { test('Should show items per row if totalCount is greater than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={30} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeTruthy(); @@ -305,24 +183,7 @@ describe('Paginated Table Component', () => { test('Should hide items per row if totalCount is less than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={1} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); @@ -331,24 +192,7 @@ describe('Paginated Table Component', () => { test('Should hide pagination if totalCount is zero', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={0} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -360,24 +204,7 @@ describe('Paginated Table Component', () => { test('should call updateActivePage with 1 when clicking to the first page', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -387,24 +214,7 @@ describe('Paginated Table Component', () => { test('Should call updateActivePage with 0 when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -417,22 +227,8 @@ describe('Paginated Table Component', () => { test('should update the page when the activePage is changed from redux', () => { const ourProps: BasicTableProps = { + ...testProps, activePage: 3, - columns: getHostsColumns(), - headerCount: 1, - headerSupplement:

{'My test supplement.'}

, - headerTitle: 'Hosts', - headerTooltip: 'My test tooltip', - headerUnit: 'Test Unit', - itemsPerRow: rowItems, - limit: 1, - loading: false, - loadPage, - pageOfItems: mockData.Hosts.edges, - showMorePagesIndicator: true, - totalCount: 10, - updateActivePage, - updateLimitPagination: (limit) => updateLimitPagination({ limit }), }; // enzyme does not allow us to pass props to child of HOC @@ -462,24 +258,7 @@ describe('Paginated Table Component', () => { test('Should call updateLimitPagination when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -494,24 +273,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -524,4 +290,41 @@ describe('Paginated Table Component', () => { ]); }); }); + + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + + test('toggleStatus=true, render table', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + true + ); + }); + + test('toggleStatus=false, hide table', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + false + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 310ab039057c2..b9de144c5735e 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -20,7 +20,7 @@ import { EuiTableRowCellProps, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { FC, memo, useState, useMemo, useEffect, ComponentType } from 'react'; +import React, { FC, memo, useState, useMemo, useEffect, ComponentType, useCallback } from 'react'; import styled from 'styled-components'; import { Direction } from '../../../../common/search_strategy'; @@ -49,6 +49,7 @@ import { useStateToaster } from '../toasters'; import * as i18n from './translations'; import { Panel } from '../panel'; import { InspectButtonContainer } from '../inspect'; +import { useQueryToggle } from '../../containers/query_toggle'; const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; @@ -113,6 +114,7 @@ export interface BasicTableProps { onChange?: (criteria: Criteria) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any pageOfItems: any[]; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; sorting?: SortingBasicTable; split?: boolean; @@ -153,6 +155,7 @@ const PaginatedTableComponent: FC = ({ loadPage, onChange = noop, pageOfItems, + setQuerySkip, showMorePagesIndicator, sorting = null, split, @@ -253,10 +256,24 @@ const PaginatedTableComponent: FC = ({ [sorting] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + return ( = ({ > {!loadingInitial && headerSupplement} - - {loadingInitial ? ( - - ) : ( - <> - - - - {itemsPerRow && itemsPerRow.length > 0 && totalCount >= itemsPerRow[0].numberOfRow && ( - - - - )} - - - - {totalCount > 0 && ( - - )} - - - {(isInspect || myLoading) && ( - - )} - - )} + {toggleStatus && + (loadingInitial ? ( + + ) : ( + <> + + + + {itemsPerRow && + itemsPerRow.length > 0 && + totalCount >= itemsPerRow[0].numberOfRow && ( + + + + )} + + + + {totalCount > 0 && ( + + )} + + + {(isInspect || myLoading) && ( + + )} + + ))} ); 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 5f2c76632aba9..944eeb8b42a57 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 @@ -41,6 +41,7 @@ import { NetworkKpiStrategyResponse, } from '../../../../common/search_strategy'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; +import * as module from '../../containers/query_toggle'; const from = '2019-06-15T06:00:00.000Z'; const to = '2019-06-18T06:00:00.000Z'; @@ -53,26 +54,37 @@ jest.mock('../charts/barchart', () => { return { BarChart: () =>
}; }); +const mockSetToggle = jest.fn(); + +jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: true, setToggleStatus: mockSetToggle })); +const mockSetQuerySkip = jest.fn(); describe('Stat Items Component', () => { const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - + const testProps = { + description: 'HOSTS', + fields: [{ key: 'hosts', value: null, color: '#6092C0', icon: 'cross' }], + from, + id: 'statItems', + key: 'mock-keys', + loading: false, + setQuerySkip: mockSetQuerySkip, + to, + narrowDateRange: mockNarrowDateRange, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); describe.each([ [ mount( - + ), @@ -81,17 +93,7 @@ describe('Stat Items Component', () => { mount( - + ), @@ -118,62 +120,59 @@ describe('Stat Items Component', () => { }); }); + const mockStatItemsData: StatItemsProps = { + ...testProps, + areaChart: [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#D36086', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#9170B8', + }, + ], + barChart: [ + { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, + { + key: 'uniqueDestinationIps', + value: [{ x: 'uniqueDestinationIps', y: 2354 }], + color: '#9170B8', + }, + ], + description: 'UNIQUE_PRIVATE_IPS', + enableAreaChart: true, + enableBarChart: true, + fields: [ + { + key: 'uniqueSourceIps', + description: 'Source', + value: 1714, + color: '#D36086', + icon: 'cross', + }, + { + key: 'uniqueDestinationIps', + description: 'Dest.', + value: 2359, + color: '#9170B8', + icon: 'cross', + }, + ], + }; + + let wrapper: ReactWrapper; describe('rendering kpis with charts', () => { - const mockStatItemsData: StatItemsProps = { - areaChart: [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#D36086', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#9170B8', - }, - ], - barChart: [ - { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, - { - key: 'uniqueDestinationIps', - value: [{ x: 'uniqueDestinationIps', y: 2354 }], - color: '#9170B8', - }, - ], - description: 'UNIQUE_PRIVATE_IPS', - enableAreaChart: true, - enableBarChart: true, - fields: [ - { - key: 'uniqueSourceIps', - description: 'Source', - value: 1714, - color: '#D36086', - icon: 'cross', - }, - { - key: 'uniqueDestinationIps', - description: 'Dest.', - value: 2359, - color: '#9170B8', - icon: 'cross', - }, - ], - from, - id: 'statItems', - key: 'mock-keys', - to, - narrowDateRange: mockNarrowDateRange, - }; - let wrapper: ReactWrapper; beforeAll(() => { wrapper = mount( @@ -202,6 +201,43 @@ describe('Stat Items Component', () => { expect(wrapper.find(EuiHorizontalRule)).toHaveLength(1); }); }); + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-stat"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + test('toggleStatus=true, render all', () => { + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(true); + }); + test('toggleStatus=false, render none', () => { + jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: false, setToggleStatus: mockSetToggle })); + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual( + false + ); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(false); + }); + }); }); describe('addValueToFields', () => { @@ -244,7 +280,9 @@ describe('useKpiMatrixStatus', () => { 'statItem', from, to, - mockNarrowDateRange + mockNarrowDateRange, + mockSetQuerySkip, + false ); return ( @@ -262,8 +300,10 @@ describe('useKpiMatrixStatus', () => { ); - - expect(wrapper.find('MockChildComponent').get(0).props).toEqual(mockEnableChartsData); + const result = { ...wrapper.find('MockChildComponent').get(0).props }; + const { setQuerySkip, ...restResult } = result; + const { setQuerySkip: a, ...restExpect } = mockEnableChartsData; + expect(restResult).toEqual(restExpect); }); test('it should not append areaChart if enableAreaChart is off', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 424920d34e2e8..6de3cc07472bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -12,13 +12,16 @@ import { EuiPanel, EuiHorizontalRule, EuiIcon, + EuiButtonIcon, + EuiLoadingSpinner, EuiTitle, IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useQueryToggle } from '../../containers/query_toggle'; import { HostsKpiStrategyResponse, @@ -34,6 +37,7 @@ import { InspectButton } from '../inspect'; import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; import { HoverVisibilityContainer } from '../hover_visibility_container'; import { LensAttributes } from '../visualization_actions/types'; +import * as i18n from '../../containers/query_toggle/translations'; import { UserskKpiStrategyResponse } from '../../../../common/search_strategy/security_solution/users'; const FlexItem = styled(EuiFlexItem)` @@ -84,6 +88,8 @@ export interface StatItemsProps extends StatItems { narrowDateRange: UpdateDateRange; to: string; showInspectButton?: boolean; + loading: boolean; + setQuerySkip: (skip: boolean) => void; } export const numberFormatter = (value: string | number): string => value.toLocaleString(); @@ -176,33 +182,27 @@ export const useKpiMatrixStatus = ( id: string, from: string, to: string, - narrowDateRange: UpdateDateRange -): StatItemsProps[] => { - const [statItemsProps, setStatItemsProps] = useState(mappings as StatItemsProps[]); - - useEffect(() => { - setStatItemsProps( - mappings.map((stat) => { - return { - ...stat, - areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, - barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, - fields: addValueToFields(stat.fields, data), - id, - key: `kpi-summary-${stat.key}`, - statKey: `${stat.key}`, - from, - to, - narrowDateRange, - }; - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); - - return statItemsProps; -}; - + narrowDateRange: UpdateDateRange, + setQuerySkip: (skip: boolean) => void, + loading: boolean +): StatItemsProps[] => + mappings.map((stat) => ({ + ...stat, + areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, + barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, + fields: addValueToFields(stat.fields, data), + id, + key: `kpi-summary-${stat.key}`, + statKey: `${stat.key}`, + from, + to, + narrowDateRange, + setQuerySkip, + loading, + })); +const StyledTitle = styled.h6` + line-height: 200%; +`; export const StatItemsComponent = React.memo( ({ areaChart, @@ -214,13 +214,15 @@ export const StatItemsComponent = React.memo( from, grow, id, - showInspectButton, + loading = false, + showInspectButton = true, index, narrowDateRange, statKey = 'item', to, barChartLensAttributes, areaChartLensAttributes, + setQuerySkip, }) => { const isBarChartDataAvailable = barChart && @@ -239,101 +241,143 @@ export const StatItemsComponent = React.memo( [from, to] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const toggle = useCallback(() => toggleQuery(!toggleStatus), [toggleQuery, toggleStatus]); + return ( - -
{description}
-
+ + + + + + + {description} + + +
- {showInspectButton && ( + {showInspectButton && toggleStatus && !loading && ( )}
+ {loading && ( + + + + + + )} + {toggleStatus && !loading && ( + <> + + {fields.map((field) => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - - {fields.map((field) => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + + +

+ {field.value != null + ? field.value.toLocaleString() + : getEmptyTagValue()}{' '} + {field.description} +

+
+ {field.lensAttributes && timerange && ( + + )} +
+
+
+
+ ))} +
+ {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( - - -

- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} - {field.description} -

-
- {field.lensAttributes && timerange && ( - - )} -
+
-
-
- ))} -
+ )} - {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} - - {enableAreaChart && from != null && to != null && ( - <> - - - - - )} - + {enableAreaChart && from != null && to != null && ( + <> + + + + + )} + + + )}
); @@ -344,6 +388,8 @@ export const StatItemsComponent = React.memo( prevProps.enableBarChart === nextProps.enableBarChart && prevProps.from === nextProps.from && prevProps.grow === nextProps.grow && + prevProps.loading === nextProps.loading && + prevProps.setQuerySkip === nextProps.setQuerySkip && prevProps.id === nextProps.id && prevProps.index === nextProps.index && prevProps.narrowDateRange === nextProps.narrowDateRange && diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts index e09dbe23d512a..138fa99ef4074 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts @@ -6,7 +6,6 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; - import { useKibana } from '../../../common/lib/kibana'; import { useMatrixHistogram, useMatrixHistogramCombined } from '.'; import { MatrixHistogramType } from '../../../../common/search_strategy'; @@ -39,6 +38,7 @@ describe('useMatrixHistogram', () => { indexNames: [], stackByField: 'event.module', startDate: new Date(Date.now()).toISOString(), + skip: false, }; afterEach(() => { @@ -145,6 +145,17 @@ describe('useMatrixHistogram', () => { mockDnsSearchStrategyResponse.rawResponse.aggregations?.dns_name_query_count.buckets ); }); + + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { ...props }; + const { rerender } = renderHook(() => useMatrixHistogram(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(3); + }); }); describe('useMatrixHistogramCombined', () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index c49a9d0438b2d..f6670c98fc0ee 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -229,6 +229,14 @@ export const useMatrixHistogram = ({ }; }, [matrixHistogramRequest, hostsSearch, skip]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + const runMatrixHistogramSearch = useCallback( (to: string, from: string) => { hostsSearch({ diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx new file mode 100644 index 0000000000000..76f1c02dcb43c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + renderHook, + act, + RenderResult, + WaitForNextUpdate, + cleanup, +} from '@testing-library/react-hooks'; +import { QueryToggle, useQueryToggle } from '.'; +import { RouteSpyState } from '../../utils/route/types'; +import { SecurityPageName } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana'; + +const mockRouteSpy: RouteSpyState = { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/', +}; +jest.mock('../../lib/kibana'); +jest.mock('../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => [mockRouteSpy], +})); + +describe('useQueryToggle', () => { + let result: RenderResult; + let waitForNextUpdate: WaitForNextUpdate; + const mockSet = jest.fn(); + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + storage: { + get: () => true, + set: mockSet, + }, + }, + }); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('Toggles local storage', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle('queryId'))); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(result.current.toggleStatus).toEqual(false); + expect(mockSet).toBeCalledWith('kibana.siem:queryId.query.toggle:overview', false); + cleanup(); + }); + it('null storage key, do not set', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle())); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(mockSet).not.toBeCalled(); + cleanup(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx new file mode 100644 index 0000000000000..53bcd6b60fc1b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useCallback, useState } from 'react'; +import { useKibana } from '../../lib/kibana'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; + +export const getUniqueStorageKey = (pageName: string, id?: string): string | null => + id && pageName.length > 0 ? `kibana.siem:${id}.query.toggle:${pageName}` : null; +export interface QueryToggle { + toggleStatus: boolean; + setToggleStatus: (b: boolean) => void; +} + +export const useQueryToggle = (id?: string): QueryToggle => { + const [{ pageName }] = useRouteSpy(); + const { + services: { storage }, + } = useKibana(); + const storageKey = getUniqueStorageKey(pageName, id); + + const [storageValue, setStorageValue] = useState( + storageKey != null ? storage.get(storageKey) ?? true : true + ); + + useEffect(() => { + if (storageKey != null) { + setStorageValue(storage.get(storageKey) ?? true); + } + }, [storage, storageKey]); + + const setToggleStatus = useCallback( + (isOpen: boolean) => { + if (storageKey != null) { + storage.set(storageKey, isOpen); + setStorageValue(isOpen); + } + }, + [storage, storageKey] + ); + + return id + ? { + toggleStatus: storageValue, + setToggleStatus, + } + : { + toggleStatus: true, + setToggleStatus: () => {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx new file mode 100644 index 0000000000000..acb64e7e6b510 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_BUTTON_TITLE = (buttonOn: boolean) => + buttonOn + ? i18n.translate('xpack.securitySolution.toggleQuery.on', { + defaultMessage: 'Open', + }) + : i18n.translate('xpack.securitySolution.toggleQuery.off', { + defaultMessage: 'Closed', + }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts index 5bfa9028a0fe8..c1513b7a0485b 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts @@ -6,7 +6,7 @@ */ import { useSearchStrategy } from './index'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { useObservable } from '@kbn/securitysolution-hook-utils'; import { FactoryQueryTypes } from '../../../../common/search_strategy'; @@ -200,4 +200,19 @@ describe('useSearchStrategy', () => { expect(start).toBeCalledWith(expect.objectContaining({ signal })); }); + it('skip = true will cancel any running request', () => { + const abortSpy = jest.fn(); + const signal = new AbortController().signal; + jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal }); + const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes; + const localProps = { + ...userSearchStrategyProps, + skip: false, + factoryQueryType, + }; + const { rerender } = renderHook(() => useSearchStrategy(localProps)); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx index 77676a83d39b6..234cf039024ba 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx @@ -96,6 +96,7 @@ export const useSearchStrategy = ({ factoryQueryType, initialResult, errorMessage, + skip = false, }: { factoryQueryType: QueryType; /** @@ -106,6 +107,7 @@ export const useSearchStrategy = ({ * Message displayed to the user on a Toast when an erro happens. */ errorMessage?: string; + skip?: boolean; }) => { const abortCtrl = useRef(new AbortController()); const { getTransformChangesIfTheyExist } = useTransforms(); @@ -154,6 +156,12 @@ export const useSearchStrategy = ({ }; }, []); + useEffect(() => { + if (skip) { + abortCtrl.current.abort(); + } + }, [skip]); + const [formatedResult, inspect] = useMemo( () => [ result diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx index d0b05587a4711..4fc47421a720e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx @@ -12,7 +12,9 @@ import { mount } from 'enzyme'; import { TestProviders } from '../../../../common/mock'; import { AlertsCountPanel } from './index'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; @@ -22,6 +24,12 @@ describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders correctly', async () => { await act(async () => { @@ -54,4 +62,38 @@ describe('AlertsCountPanel', () => { }); }); }); + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 04b8f482fd121..1c0e2144ad9d4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -6,7 +6,7 @@ */ import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import React, { memo, useMemo, useState, useEffect } from 'react'; +import React, { memo, useMemo, useState, useEffect, useCallback } from 'react'; import uuid from 'uuid'; import type { Filter, Query } from '@kbn/es-query'; @@ -24,6 +24,7 @@ import type { AlertsCountAggregation } from './types'; import { DEFAULT_STACK_BY_FIELD } from '../common/config'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -64,6 +65,20 @@ export const AlertsCountPanel = memo( } }, [query, filters]); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_COUNT_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const { loading: isLoadingAlerts, data: alertsData, @@ -80,6 +95,7 @@ export const AlertsCountPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); useEffect(() => { @@ -99,21 +115,26 @@ export const AlertsCountPanel = memo( }); return ( - - + + - + {toggleStatus && ( + + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 29e18a1c49c12..3135e2e173793 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -12,9 +12,13 @@ import { mount } from 'enzyme'; import type { Filter } from '@kbn/es-query'; import { TestProviders } from '../../../../common/mock'; import { SecurityPageName } from '../../../../app/types'; +import { MatrixLoader } from '../../../../common/components/matrix_histogram/matrix_loader'; import { AlertsHistogramPanel } from './index'; import * as helpers from './helpers'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; + +jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -91,6 +95,12 @@ describe('AlertsHistogramPanel', () => { updateDateRange: jest.fn(), }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); + afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); @@ -339,4 +349,40 @@ describe('AlertsHistogramPanel', () => { `); }); }); + + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 571f656389f6a..84476c3ee6885 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -45,6 +45,7 @@ import type { AlertsStackByField } from '../common/types'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -116,6 +117,19 @@ export const AlertsHistogramPanel = memo( onlyField == null ? defaultStackByOption : onlyField ); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const { loading: isLoadingAlerts, data: alertsData, @@ -132,6 +146,7 @@ export const AlertsHistogramPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); const kibana = useKibana(); @@ -270,17 +285,21 @@ export const AlertsHistogramPanel = memo( ); return ( - + ( - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 6a56f7bc220ac..27f33409ae1a5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -12,17 +12,23 @@ import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; import { useStackByFields } from './hooks'; import * as i18n from './translations'; -export const KpiPanel = styled(EuiPanel)<{ height?: number }>` +export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>` display: flex; flex-direction: column; position: relative; overflow: hidden; - - height: ${MOBILE_PANEL_HEIGHT}px; - @media only screen and (min-width: ${(props) => props.theme.eui.euiBreakpoints.m}) { + ${({ $toggleStatus }) => + $toggleStatus && + ` height: ${PANEL_HEIGHT}px; + `} } + ${({ $toggleStatus }) => + $toggleStatus && + ` + height: ${MOBILE_PANEL_HEIGHT}px; + `} `; interface StackedBySelectProps { selected: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx index 277e2008601dc..5ed7a219e5068 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx @@ -129,4 +129,22 @@ describe('useQueryAlerts', () => { }); }); }); + + test('skip', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + await act(async () => { + const localProps = { query: mockAlertsQuery, indexName, skip: false }; + const { rerender, waitForNextUpdate } = renderHook< + [object, string], + ReturnQueryAlerts + >(() => useQueryAlerts(localProps)); + await waitForNextUpdate(); + await waitForNextUpdate(); + + localProps.skip = true; + act(() => rerender()); + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index b2bbcdf277992..2b98987e52675 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -94,6 +94,12 @@ export const useQueryAlerts = ({ if (!isEmpty(query) && !skip) { fetchData(); } + if (skip) { + setLoading(false); + isSubscribed = false; + abortCtrl.abort(); + } + return () => { isSubscribed = false; abortCtrl.abort(); diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap index ed119568cdcb3..bffd5e2261ad9 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap @@ -105,6 +105,7 @@ exports[`Authentication Table Component rendering it renders the authentication isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={54} type="page" 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 14dc1769dbd05..2ec333e335639 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 @@ -45,6 +45,7 @@ describe('Authentication Table Component', () => { isInspect={false} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={getOr( false, 'showMorePagesIndicator', diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx index 4402f6a210947..2bbda82e15315 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx @@ -43,6 +43,7 @@ interface AuthenticationTableProps { loadPage: (newActivePage: number) => void; id: string; isInspect: boolean; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -78,6 +79,7 @@ const AuthenticationTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -133,6 +135,7 @@ const AuthenticationTableComponent: React.FC = ({ loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx index e4130eee21909..f4da6983fc590 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx @@ -54,6 +54,7 @@ interface HostRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: hostsModel.HostsType; @@ -71,6 +72,7 @@ const HostRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -207,6 +209,7 @@ const HostRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap index 59a00cbf190f6..f646fc12c4697 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap @@ -36,6 +36,7 @@ exports[`Hosts Table rendering it renders the default Hosts table 1`] = ` isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={-1} type="page" 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 71efbb0a44d15..43dc31c68d1bc 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 @@ -69,6 +69,7 @@ describe('Hosts Table', () => { fakeTotalCount={0} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} totalCount={-1} type={hostsModel.HostsType.page} @@ -91,6 +92,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -113,6 +115,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -136,6 +139,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index 01306004844d8..42c8254ffd183 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -42,6 +42,7 @@ interface HostsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -77,6 +78,7 @@ const HostsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -172,6 +174,7 @@ const HostsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..164b88399bbe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiAuthentications } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/authentications'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Authentications KPI', () => { + const mockUseHostsKpiAuthentications = useHostsKpiAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiAuthentications.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index 1158c842e04cb..f12eca88ffc95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUserAuthenticationsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area'; import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar'; import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success'; import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure'; -import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useHostsKpiAuthentications, ID } from '../../../containers/kpi_hosts/authentications'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiAuthentications({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index e3460ec22e73e..4296ae4984b95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -42,10 +42,11 @@ interface KpiBaseComponentProps { from: string; to: string; narrowDateRange: UpdateDateRange; + setQuerySkip: (skip: boolean) => void; } export const KpiBaseComponent = React.memo( - ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange, setQuerySkip }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); @@ -57,13 +58,11 @@ export const KpiBaseComponent = React.memo( id, from, to, - narrowDateRange + narrowDateRange, + setQuerySkip, + loading ); - if (loading) { - return ; - } - return ( @@ -87,11 +86,3 @@ export const KpiBaseComponent = React.memo( KpiBaseComponent.displayName = 'KpiBaseComponent'; export const KpiBaseComponentManage = manageQuery(KpiBaseComponent); - -export const KpiBaseComponentLoader: React.FC = () => ( - - - - - -); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..49b6986515564 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiHosts } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/hosts'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Hosts KPI', () => { + const mockUseHostsKpiHosts = useHostsKpiHosts as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiHosts.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx index 79118b66a3f71..b29bdddd44e35 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiHostAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; import { kpiHostMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; -import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useHostsKpiHosts, ID } from '../../../containers/kpi_hosts/hosts'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -42,12 +43,17 @@ const HostsKpiHostsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiHosts({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -62,6 +68,7 @@ const HostsKpiHostsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index f515490252d40..0a86a9006b637 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -11,6 +11,7 @@ import { EuiHorizontalRule, EuiIcon, EuiPanel, + EuiLoadingSpinner, EuiTitle, EuiText, } from '@elastic/eui'; @@ -22,7 +23,6 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS, } from '../../../../common/components/inspect'; -import { KpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; @@ -36,6 +36,13 @@ import { HoverVisibilityContainer } from '../../../../common/components/hover_vi import { KpiRiskScoreStrategyResponse, RiskSeverity } from '../../../../../common/search_strategy'; import { RiskScore } from '../../../../common/components/severity/common'; +const KpiBaseComponentLoader: React.FC = () => ( + + + + + +); const QUERY_ID = 'hostsKpiRiskyHostsQuery'; const HostCount = styled(EuiText)` diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..20de5db340b5e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiUniqueIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/unique_ips'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique IPs KPI', () => { + const mockUseHostsKpiUniqueIps = useHostsKpiUniqueIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiUniqueIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx index ef7bdfa1dc031..ef032d041db7d 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area'; import { kpiUniqueIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar'; import { kpiUniqueIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric'; import { kpiUniqueIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric'; -import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useHostsKpiUniqueIps, ID } from '../../../containers/kpi_hosts/unique_ips'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiUniqueIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx index 2f3a414344cfc..5ff8696ae5be3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx @@ -5,16 +5,30 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import { TopHostScoreContributors } from '.'; import { TestProviders } from '../../../common/mock'; import { useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; - +const testProps = { + setQuery: jest.fn(), + deleteQuery: jest.fn(), + hostName: 'test-host-name', + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', +}; describe('Host Risk Flyout', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders', () => { useHostRiskScoreMock.mockReturnValueOnce([ true, @@ -26,13 +40,7 @@ describe('Host Risk Flyout', () => { const { queryByTestId } = render( - + ); @@ -69,13 +77,7 @@ describe('Host Risk Flyout', () => { const { queryAllByRole } = render( - + ); @@ -83,4 +85,66 @@ describe('Host Risk Flyout', () => { expect(queryAllByRole('row')[2]).toHaveTextContent('second'); expect(queryAllByRole('row')[3]).toHaveTextContent('third'); }); + + describe('toggleQuery', () => { + beforeEach(() => { + useHostRiskScoreMock.mockReturnValue([ + true, + { + data: [], + isModuleEnabled: true, + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const { getByTestId } = render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + fireEvent.click(getByTestId('query-toggle-header')); + expect(mockSetToggle).toBeCalledWith(false); + expect(useHostRiskScoreMock.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeTruthy(); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeFalsy(); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx index 8811a6b64e7fc..a3b7022ee83ef 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, @@ -27,6 +27,7 @@ import { HostsComponentsQueryProps } from '../../pages/navigation/types'; import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface TopHostScoreContributorsProps extends Pick { @@ -77,11 +78,27 @@ const TopHostScoreContributorsComponent: React.FC const sort = useMemo(() => ({ field: RiskScoreFields.timestamp, direction: Direction.desc }), []); + const { toggleStatus, setToggleStatus } = useQueryToggle(QUERY_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { data, refetch, inspect }] = useHostRiskScore({ filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, timerange, onlyLatest: false, sort, + skip: querySkip, pagination: { querySize: 1, cursorStart: 0, @@ -119,24 +136,37 @@ const TopHostScoreContributorsComponent: React.FC - - - - - - - - - - - + {toggleStatus && ( + + + + )} + + {toggleStatus && ( + + + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap index a93c4062e8808..19a6018f6b680 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap @@ -205,6 +205,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={5} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx index 29d3f110e8181..300abc60818cb 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx @@ -36,21 +36,24 @@ describe('Uncommon Process Table Component', () => { const loadPage = jest.fn(); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'uncommonProcess', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: hostsModel.HostsType.page, + }; + describe('rendering', () => { test('it renders the default Uncommon process table', () => { const wrapper = shallow( - + ); @@ -60,17 +63,7 @@ describe('Uncommon Process Table Component', () => { test('it has a double dash (empty value) without any hosts at all', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(0).find('.euiTableRowCell').at(3).text()).toBe( @@ -81,17 +74,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single host without any extra comma when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -103,17 +86,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single link when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -125,17 +98,7 @@ describe('Uncommon Process Table Component', () => { test('it has a comma separated list of hosts when the number of hosts is greater than 1', () => { const wrapper = mount( - + ); @@ -147,17 +110,7 @@ describe('Uncommon Process Table Component', () => { test('it has 2 links when the number of hosts is equal to 2', () => { const wrapper = mount( - + ); @@ -169,17 +122,7 @@ describe('Uncommon Process Table Component', () => { test('it is empty when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(3).find('.euiTableRowCell').at(3).text()).toBe( @@ -190,17 +133,7 @@ describe('Uncommon Process Table Component', () => { test('it has no link when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect( @@ -211,17 +144,7 @@ describe('Uncommon Process Table Component', () => { test('it is returns two hosts when others are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(4).find('.euiTableRowCell').at(3).text()).toBe( diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx index 0af27bdb0ba18..cbdae1747e5f6 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx @@ -30,6 +30,7 @@ interface UncommonProcessTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -72,6 +73,7 @@ const UncommonProcessTableComponent = React.memo( loading, loadPage, totalCount, + setQuerySkip, showMorePagesIndicator, type, }) => { @@ -125,6 +127,7 @@ const UncommonProcessTableComponent = React.memo( loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx new file mode 100644 index 0000000000000..1f6ee4cb276ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from './index'; +import { HostsType } from '../../store/model'; + +describe('authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index f446380e54937..1ff27e4b29917 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -36,7 +36,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsAuthenticationsQuery'; +export const ID = 'hostsAuthenticationsQuery'; export interface AuthenticationArgs { authentications: AuthenticationsEdges[]; @@ -215,5 +215,13 @@ export const useAuthentications = ({ }; }, [authenticationsRequest, authenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, authenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx new file mode 100644 index 0000000000000..df64f4cd6f81a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from './index'; +import { HostsType } from '../../store/model'; + +describe('useAllHost', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAllHost(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 1a9e86755cf7d..c4259e8a5a737 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -217,5 +217,13 @@ export const useAllHost = ({ }; }, [hostsRequest, hostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..f62fc3a77786e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiAuthentications } from './index'; + +describe('kpi hosts - authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index c15c68d246f14..9fa38c14e2ea4 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiAuthenticationsQuery'; +export const ID = 'hostsKpiAuthenticationsQuery'; export interface HostsKpiAuthenticationsArgs extends Omit { @@ -165,5 +165,13 @@ export const useHostsKpiAuthentications = ({ }; }, [hostsKpiAuthenticationsRequest, hostsKpiAuthenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiAuthenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..f12b92f0661bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiHosts } from './index'; + +describe('kpi hosts - hosts', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiHosts(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index fdce4dfe79591..63f0476c2b631 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiHostsQuery'; +export const ID = 'hostsKpiHostsQuery'; export interface HostsKpiHostsArgs extends Omit { id: string; @@ -155,5 +155,13 @@ export const useHostsKpiHosts = ({ }; }, [hostsKpiHostsRequest, hostsKpiHostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiHostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx deleted file mode 100644 index 8473d3971c66f..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './authentications'; -export * from './hosts'; -export * from './unique_ips'; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..ec8c73ad1d6a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiUniqueIps } from './index'; + +describe('kpi hosts - Unique Ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiUniqueIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index 5b9eeb2710ff3..25a9f76daf40f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiUniqueIpsQuery'; +export const ID = 'hostsKpiUniqueIpsQuery'; export interface HostsKpiUniqueIpsArgs extends Omit { @@ -163,5 +163,13 @@ export const useHostsKpiUniqueIps = ({ }; }, [hostsKpiUniqueIpsRequest, hostsKpiUniqueIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiUniqueIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx new file mode 100644 index 0000000000000..e334465fdbc1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useUncommonProcesses } from './index'; +import { HostsType } from '../../store/model'; + +describe('useUncommonProcesses', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useUncommonProcesses(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 9548027520bd1..d196c4ea01af1 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -34,7 +34,7 @@ import { InspectResponse } from '../../../types'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsUncommonProcessesQuery'; +export const ID = 'hostsUncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; @@ -202,5 +202,13 @@ export const useUncommonProcesses = ({ }; }, [uncommonProcessesRequest, uncommonProcessesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, uncommonProcessesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx new file mode 100644 index 0000000000000..9d31b477a851a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AuthenticationsQueryTabBody } from './authentications_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Authentications query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx index 879f0fce02fd5..1096085b93016 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -6,7 +6,7 @@ */ import { getOr } from 'lodash/fp'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { AuthenticationTable } from '../../components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; import { useAuthentications } from '../../containers/authentications'; @@ -22,6 +22,7 @@ import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { authenticationLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/authentication'; import { LensAttributes } from '../../../common/components/visualization_actions/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -76,6 +77,11 @@ const AuthenticationsQueryTabBodyComponent: React.FC startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -84,7 +90,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -119,6 +125,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC loading={loading} loadPage={loadPage} refetch={refetch} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} totalCount={totalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..8b3a05cc3d88c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useHostRiskScore, useHostRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostRiskScoreQueryTabBody } from './host_risk_score_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Host risk score query tab body', () => { + const mockUseHostRiskScore = useHostRiskScore as jest.Mock; + const mockUseHostRiskScoreKpi = useHostRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + mockUseHostRiskScore.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx index 11a422fa0cd3d..11ba8d154cd81 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { HostsComponentsQueryProps } from './types'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -18,6 +18,7 @@ import { useHostRiskScore, useHostRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); @@ -43,15 +44,22 @@ export const HostRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(HostRiskScoreQueryId.HOSTS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useHostRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useHostRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const HostRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx new file mode 100644 index 0000000000000..487934f30e8d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from '../../containers/hosts'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostsQueryTabBody } from './hosts_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/hosts'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Hosts query tab body', () => { + const mockUseAllHost = useAllHost as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAllHost.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx index cc43cfed4619d..b72e6572849d1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAllHost } from '../../containers/hosts'; +import React, { useEffect, useState } from 'react'; +import { useAllHost, ID } from '../../containers/hosts'; import { HostsComponentsQueryProps } from './types'; import { HostsTable } from '../../components/hosts_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostsTableManage = manageQuery(HostsTable); @@ -25,8 +26,21 @@ export const HostsQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }] = - useAllHost({ docValueFields, endDate, filterQuery, indexNames, skip, startDate, type }); + useAllHost({ + docValueFields, + endDate, + filterQuery, + indexNames, + skip: querySkip, + startDate, + type, + }); return ( { + const mockUseUncommonProcesses = useUncommonProcesses as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUncommonProcesses.mockReturnValue([ + false, + { + uncommonProcesses: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx index 236b732a5af05..f6957fedd83c5 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useUncommonProcesses } from '../../containers/uncommon_processes'; +import React, { useEffect, useState } from 'react'; +import { useUncommonProcesses, ID } from '../../containers/uncommon_processes'; import { HostsComponentsQueryProps } from './types'; import { UncommonProcessTable } from '../../components/uncommon_process_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UncommonProcessTableManage = manageQuery(UncommonProcessTable); @@ -25,6 +26,11 @@ export const UncommonProcessQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { uncommonProcesses, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -33,7 +39,7 @@ export const UncommonProcessQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const UncommonProcessQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap index 8835a3ac390f3..966512170c156 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Embeddable it renders 1`] = `
(({ children }) => ( -
+
{children} diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx index 4b8a5b6dd9940..2166d6b495e75 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx @@ -109,7 +109,7 @@ describe('EmbeddedMapComponent', () => { beforeEach(() => { setQuery.mockClear(); - mockGetStorage.mockReturnValue(false); + mockGetStorage.mockReturnValue(true); }); afterEach(() => { @@ -190,36 +190,40 @@ describe('EmbeddedMapComponent', () => { }); test('map hidden on close', async () => { + mockGetStorage.mockReturnValue(false); const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); + const container = wrapper.find('[data-test-subj="false-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', true); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); }); }); test('map visible on open', async () => { - mockGetStorage.mockReturnValue(true); - const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); const container = wrapper.find('[data-test-subj="true-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', false); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 803688bf21343..083f858dc7742 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -245,6 +245,29 @@ export const EmbeddedMapComponent = ({ [storage] ); + const content = useMemo(() => { + if (!storageValue) { + return null; + } + return ( + + + + + + + {isIndexError ? ( + + ) : embeddable != null ? ( + + ) : ( + + )} + + + ); + }, [embeddable, isIndexError, portalNode, services, storageValue]); + return isError ? null : ( - - - - - - - {isIndexError ? ( - - ) : embeddable != null ? ( - - ) : ( - - )} - - + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..d5dee1b84f8d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiDns } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/dns'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('DNS KPI', () => { + const mockUseNetworkKpiDns = useNetworkKpiDns as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiDns.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx index 6291e7fd4dc12..94e81c2d80d4a 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiDnsQueriesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_dns_queries'; +import { useNetworkKpiDns, ID } from '../../../containers/kpi_network/dns'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -38,12 +39,17 @@ const NetworkKpiDnsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiDns({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -58,6 +64,7 @@ const NetworkKpiDnsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts index 6f35c4dead250..f5ed1ebde6992 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts @@ -227,7 +227,9 @@ export const mockEnableChartsData = { ], from: '2019-06-15T06:00:00.000Z', id: 'statItem', + loading: false, statKey: 'UniqueIps', + setQuerySkip: jest.fn(), to: '2019-06-18T06:00:00.000Z', narrowDateRange: mockNarrowDateRange, areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..87f1a173740f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiNetworkEvents } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/network_events'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Network Events KPI', () => { + const mockUseNetworkKpiNetworkEvents = useNetworkKpiNetworkEvents as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiNetworkEvents.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx index ad2487b65f1de..52aa98a117afa 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx @@ -5,16 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; - +import { ID, useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiNetworkEventsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_network_events'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -43,12 +43,17 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiNetworkEvents({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -63,6 +68,7 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..28bf73eb6b2d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiTlsHandshakes } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/tls_handshakes'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('TLS Handshakes KPI', () => { + const mockUseNetworkKpiTlsHandshakes = useNetworkKpiTlsHandshakes as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiTlsHandshakes.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx index 0bdbd0a23d9f1..c25a4cd140108 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiTlsHandshakesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes'; +import { useNetworkKpiTlsHandshakes, ID } from '../../../containers/kpi_network/tls_handshakes'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiTlsHandshakes({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..c1a28bdc28692 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniqueFlows } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_flows'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Flows KPI', () => { + const mockUseNetworkKpiUniqueFlows = useNetworkKpiUniqueFlows as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniqueFlows.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx index 5c3624130b36f..d6874818ab901 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueFlowIdsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids'; +import { useNetworkKpiUniqueFlows, ID } from '../../../containers/kpi_network/unique_flows'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniqueFlows({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..25807f3dc2cad --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniquePrivateIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_private_ips'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Private IPs KPI', () => { + const mockUseNetworkKpiUniquePrivateIps = useNetworkKpiUniquePrivateIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniquePrivateIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx index e546deb7019e8..91791d09f8113 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { + useNetworkKpiUniquePrivateIps, + ID, +} from '../../../containers/kpi_network/unique_private_ips'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiUniquePrivateIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric'; @@ -17,6 +20,7 @@ import { kpiUniquePrivateIpsDestinationMetricLensAttributes } from '../../../../ import { kpiUniquePrivateIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area'; import { kpiUniquePrivateIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis2 = euiVisColorPalette[2]; @@ -62,12 +66,17 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniquePrivateIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -82,6 +91,7 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap index 0119859d37672..c43df33721bf1 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap @@ -141,6 +141,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={80} type="page" 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 fc28067866146..2757baef2c1f4 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 @@ -34,6 +34,19 @@ describe('NetworkTopNFlow Table Component', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'dns', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; + beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -42,17 +55,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table', () => { const wrapper = shallow( - + ); @@ -64,17 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx index 016a40f7e2a17..a87908d27e63d 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx @@ -32,6 +32,7 @@ interface NetworkDnsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -56,6 +57,7 @@ const NetworkDnsTableComponent: React.FC = ({ loading, loadPage, showMorePagesIndicator, + setQuerySkip, totalCount, type, }) => { @@ -153,6 +155,7 @@ const NetworkDnsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap index c5df0f6603fbf..c26c85d311959 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap @@ -95,6 +95,7 @@ exports[`NetworkHttp Table Component rendering it renders the default NetworkHtt isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={4} type="page" 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 2a85b31791f5a..e8bac5e54765c 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 @@ -31,6 +31,18 @@ jest.mock('../../../common/components/link_to'); describe('NetworkHttp Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'http', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,17 +56,7 @@ describe('NetworkHttp Table Component', () => { test('it renders the default NetworkHttp table', () => { const wrapper = shallow( - + ); @@ -66,17 +68,7 @@ describe('NetworkHttp Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx index 2f0c4a105606c..5bdfd45951292 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx @@ -23,6 +23,7 @@ interface NetworkHttpTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -46,6 +47,7 @@ const NetworkHttpTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -123,6 +125,7 @@ const NetworkHttpTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap index ecf7d2d0cd16f..cd13be9cef38b 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap @@ -151,6 +151,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the IP Details isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -308,6 +309,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the default Ne isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" 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 a0727fad65f18..12dc41961bdf5 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 @@ -33,6 +33,24 @@ describe('NetworkTopCountries Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; const mount = useMountAppended(); + const defaultProps = { + data: mockData.NetworkTopCountries.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.NetworkTopCountries.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topCountriesSource', + indexPattern: mockIndexPattern, + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr( + false, + 'showMorePagesIndicator', + mockData.NetworkTopCountries.pageInfo + ), + totalCount: mockData.NetworkTopCountries.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -45,23 +63,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the default NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -70,23 +72,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the IP Details NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -98,23 +84,7 @@ describe('NetworkTopCountries Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topCountriesSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx index 80de694f89484..00c9c7d0aaf30 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx @@ -35,6 +35,7 @@ interface NetworkTopCountriesTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -62,6 +63,7 @@ const NetworkTopCountriesTableComponent: React.FC isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -170,6 +172,7 @@ const NetworkTopCountriesTableComponent: React.FC loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: sort.direction }} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap index 07874f9f39f0b..7909eba5b0d88 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap @@ -99,6 +99,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -204,6 +205,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" 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 e2b9447b58806..b5df028f4d7a4 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 @@ -35,6 +35,19 @@ describe('NetworkTopNFlow Table Component', () => { const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topNFlowSource', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,18 +57,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the Network page', () => { const wrapper = shallow( - + ); @@ -65,18 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the IP Details page', () => { const wrapper = shallow( - + ); @@ -88,18 +79,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topNFlowSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx index a612d3e4e1093..12895226a82eb 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx @@ -31,6 +31,7 @@ interface NetworkTopNFlowTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -57,6 +58,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -166,6 +168,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} 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 3a1a5efef6b89..a54b219985817 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 @@ -29,7 +29,18 @@ jest.mock('../../../common/lib/kibana'); describe('Tls Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; - + const defaultProps = { + data: mockTlsData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockTlsData.pageInfo), + id: 'tls', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockTlsData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); @@ -42,17 +53,7 @@ describe('Tls Table Component', () => { test('it renders the default Domains table', () => { const wrapper = shallow( - + ); @@ -64,17 +65,7 @@ describe('Tls Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.tls.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx index 34a218db39fac..60079e50f27ce 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx @@ -33,6 +33,7 @@ interface TlsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -58,6 +59,7 @@ const TlsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -135,6 +137,7 @@ const TlsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} 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 3861433b4dcb0..95e014332d42a 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 @@ -40,22 +40,25 @@ describe('Users Table Component', () => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); + const defaultProps = { + data: mockUsersData.edges, + flowTarget: FlowTarget.source, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockUsersData.pageInfo), + id: 'user', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockUsersData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; + describe('Rendering', () => { test('it renders the default Users table', () => { const wrapper = shallow( - + ); @@ -67,18 +70,7 @@ describe('Users Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.users.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx index 66c36208fd98a..efbe5b7d1d010 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx @@ -38,6 +38,7 @@ interface UsersTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -64,6 +65,7 @@ const UsersTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -141,6 +143,7 @@ const UsersTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..44b8472a0606c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiDns } from './index'; + +describe('kpi network - dns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index 63fb751572b0b..89f58f547bd75 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiDnsQuery'; +export const ID = 'networkKpiDnsQuery'; export interface NetworkKpiDnsArgs { dnsQueries: number; @@ -160,5 +160,13 @@ export const useNetworkKpiDns = ({ }; }, [networkKpiDnsRequest, networkKpiDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx deleted file mode 100644 index 550cefcf13e92..0000000000000 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './dns'; -export * from './network_events'; -export * from './tls_handshakes'; -export * from './unique_flows'; -export * from './unique_private_ips'; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..4171a86fae9cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiNetworkEvents } from './index'; + +describe('kpi network - network events', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiNetworkEvents(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index 4ecf455a31724..51a5367446b6e 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiNetworkEventsQuery'; +export const ID = 'networkKpiNetworkEventsQuery'; export interface NetworkKpiNetworkEventsArgs { networkEvents: number; @@ -163,5 +163,13 @@ export const useNetworkKpiNetworkEvents = ({ }; }, [networkKpiNetworkEventsRequest, networkKpiNetworkEventsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiNetworkEventsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..bad0e6ad71512 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiTlsHandshakes } from './index'; + +describe('kpi network - tls handshakes', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiTlsHandshakes(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index 2dbf909334b15..ba42d79ad0eed 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiTlsHandshakesQuery'; +export const ID = 'networkKpiTlsHandshakesQuery'; export interface NetworkKpiTlsHandshakesArgs { tlsHandshakes: number; @@ -163,5 +163,13 @@ export const useNetworkKpiTlsHandshakes = ({ }; }, [networkKpiTlsHandshakesRequest, networkKpiTlsHandshakesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiTlsHandshakesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..83cb2a40aabce --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniqueFlows } from './index'; + +describe('kpi network - unique flows', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniqueFlows(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index 612aac175fd9a..130efc8d755a6 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -29,7 +29,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniqueFlowsQuery'; +export const ID = 'networkKpiUniqueFlowsQuery'; export interface NetworkKpiUniqueFlowsArgs { uniqueFlowId: number; @@ -84,7 +84,6 @@ export const useNetworkKpiUniqueFlows = ({ const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search( request, @@ -155,5 +154,13 @@ export const useNetworkKpiUniqueFlows = ({ }; }, [networkKpiUniqueFlowsRequest, networkKpiUniqueFlowsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniqueFlowsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..370c4e671e886 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniquePrivateIps } from './index'; + +describe('kpi network - unique private ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniquePrivateIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 42a8e30a8f906..b68c4fcb698c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -31,7 +31,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniquePrivateIpsQuery'; +export const ID = 'networkKpiUniquePrivateIpsQuery'; export interface NetworkKpiUniquePrivateIpsArgs { uniqueDestinationPrivateIps: number; @@ -175,5 +175,13 @@ export const useNetworkKpiUniquePrivateIps = ({ }; }, [networkKpiUniquePrivateIpsRequest, networkKpiUniquePrivateIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniquePrivateIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx new file mode 100644 index 0000000000000..f303cdf85a5f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkDns } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkDns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index 47e60f27a7dbd..86949777dd535 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -32,7 +32,7 @@ import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkDnsQuery'; +export const ID = 'networkDnsQuery'; export interface NetworkDnsArgs { id: string; @@ -207,5 +207,13 @@ export const useNetworkDns = ({ }; }, [networkDnsRequest, networkDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx new file mode 100644 index 0000000000000..b687896efcea4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkHttp } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkHttp', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkHttp(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 98105f5cac25a..eba2b22f30e29 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { getInspectResponse } from '../../../helpers'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkHttpQuery'; +export const ID = 'networkHttpQuery'; export interface NetworkHttpArgs { id: string; @@ -94,7 +94,7 @@ export const useNetworkHttp = ({ const [networkHttpResponse, setNetworkHttpResponse] = useState({ networkHttp: [], - id: ID, + id, inspect: { dsl: [], response: [], @@ -116,11 +116,9 @@ export const useNetworkHttp = ({ if (request == null || skip) { return; } - const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search(request, { strategy: 'securitySolutionSearchStrategy', @@ -193,5 +191,13 @@ export const useNetworkHttp = ({ }; }, [networkHttpRequest, networkHttpSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkHttpResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx new file mode 100644 index 0000000000000..fe7507c85567a --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopCountries } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopCountries', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopCountries(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index f64ee85ab7cf0..6110e84804fe3 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopCountriesQuery'; +export const ID = 'networkTopCountriesQuery'; export interface NetworkTopCountriesArgs { id: string; @@ -218,5 +218,13 @@ export const useNetworkTopCountries = ({ }; }, [networkTopCountriesRequest, networkTopCountriesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopCountriesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx new file mode 100644 index 0000000000000..c31dec3ce0aed --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopNFlow } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopNFlow', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopNFlow(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 0b4c164782f3d..022b76c315c17 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopNFlowQuery'; +export const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; @@ -215,5 +215,13 @@ export const useNetworkTopNFlow = ({ }; }, [networkTopNFlowRequest, networkTopNFlowSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopNFlowResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx new file mode 100644 index 0000000000000..6b236d4ddfb20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTls } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTls', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + ip: '1.1.1.1', + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTls(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index 754f0cac8868c..ed771455446c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -29,7 +29,7 @@ import { getInspectResponse } from '../../../helpers'; import { FlowTargetSourceDest, PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTlsQuery'; +export const ID = 'networkTlsQuery'; export interface NetworkTlsArgs { id: string; @@ -196,5 +196,13 @@ export const useNetworkTls = ({ }; }, [networkTlsRequest, networkTlsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTlsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx new file mode 100644 index 0000000000000..4a6c1fac4191c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkUsers } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTarget } from '../../../../common/search_strategy'; + +describe('useNetworkUsers', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + ip: '1.1.1.1', + flowTarget: FlowTarget.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkUsers(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index d4be09f97591d..9ad2c59f6bb79 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkUsersQuery'; +export const ID = 'networkUsersQuery'; export interface NetworkUsersArgs { id: string; @@ -195,5 +195,13 @@ export const useNetworkUsers = ({ }; }, [networkUsersRequest, networkUsersSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkUsersResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx index 4a4004b9a5f0c..d615bd8264b4b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { OwnProps } from './types'; -import { useNetworkHttp } from '../../containers/network_http'; +import { useNetworkHttp, ID } from '../../containers/network_http'; import { NetworkHttpTable } from '../../components/network_http_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -24,6 +25,11 @@ export const NetworkHttpQueryTable = ({ startDate, type, }: OwnProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const NetworkHttpQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -48,6 +54,7 @@ export const NetworkHttpQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx index 742f0f6ff9a9d..4243635ebb218 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -26,6 +27,11 @@ export const NetworkTopCountriesQueryTable = ({ type, indexPattern, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const NetworkTopCountriesQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -53,6 +59,7 @@ export const NetworkTopCountriesQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx index 374dd6e6564e3..3df5397600c12 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow, ID } from '../../containers/network_top_n_flow'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -25,6 +26,11 @@ export const NetworkTopNFlowQueryTable = ({ startDate, type, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const NetworkTopNFlowQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -51,6 +57,7 @@ export const NetworkTopNFlowQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx index d3da639c8cf98..f4539e1ffc63d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { TlsTable } from '../../components/tls_table'; -import { useNetworkTls } from '../../containers/tls'; +import { ID, useNetworkTls } from '../../containers/tls'; import { TlsQueryTableComponentProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ export const TlsQueryTable = ({ startDate, type, }: TlsQueryTableComponentProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ export const TlsQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const TlsQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx index a73835985d7c5..9eb27c399ffbf 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkUsers } from '../../containers/users'; +import { useNetworkUsers, ID } from '../../containers/users'; import { NetworkComponentsQueryProps } from './types'; import { UsersTable } from '../../components/users_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UsersTableManage = manageQuery(UsersTable); @@ -24,6 +25,11 @@ export const UsersQueryTable = ({ startDate, type, }: NetworkComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, networkUsers, totalCount, pageInfo, loadPage, refetch }, @@ -32,7 +38,7 @@ export const UsersQueryTable = ({ filterQuery, flowTarget, ip, - skip, + skip: querySkip, startDate, }); @@ -49,6 +55,7 @@ export const UsersQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx index e4bb00d1cb632..b390ccdcfff82 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps as CountriesQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -27,6 +28,11 @@ export const CountriesQueryTabBody = ({ indexPattern, flowTarget, }: CountriesQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const CountriesQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -53,6 +59,7 @@ export const CountriesQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 21404690438a0..0ad309522a3e5 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React, { useEffect, useCallback, useMemo } from 'react'; +import React, { useEffect, useCallback, useMemo, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkDnsTable } from '../../components/network_dns_table'; -import { useNetworkDns } from '../../containers/network_dns'; +import { useNetworkDns, ID } from '../../containers/network_dns'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkComponentQueryProps } from './types'; @@ -24,6 +24,7 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security import { networkSelectors } from '../../store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { dnsTopDomainsLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/dns_top_domains'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HISTOGRAM_ID = 'networkDnsHistogramQuery'; @@ -72,6 +73,11 @@ const DnsQueryTabBodyComponent: React.FC = ({ }; }, [deleteQuery]); + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { totalCount, networkDns, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -80,7 +86,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -122,6 +128,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx index bf9b0079650b2..98570a2f2f740 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkHttpTable } from '../../components/network_http_table'; -import { useNetworkHttp } from '../../containers/network_http'; +import { ID, useNetworkHttp } from '../../containers/network_http'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { HttpQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -25,6 +26,11 @@ export const HttpQueryTabBody = ({ startDate, setQuery, }: HttpQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const HttpQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -48,6 +54,7 @@ export const HttpQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx index aa21fe6066415..a497a35fe3551 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { ID, useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -26,6 +27,11 @@ export const IPsQueryTabBody = ({ setQuery, flowTarget, }: IPsQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const IPsQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -51,6 +57,7 @@ export const IPsQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx index 58c6f755b9175..c06a26f5d9192 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkTls } from '../../../network/containers/tls'; +import { useNetworkTls, ID } from '../../containers/tls'; import { TlsTable } from '../../components/tls_table'; import { TlsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ const TlsQueryTabBodyComponent: React.FC = ({ startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> 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 1295693db506f..173710a7700e8 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 @@ -21,9 +21,12 @@ import { import { OverviewHost } from '.'; import { createStore, State } from '../../../common/store'; import { useHostOverview } from '../../containers/overview_host'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/containers/query_toggle'); const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; @@ -32,6 +35,7 @@ const testProps = { indexNames: [], setQuery: jest.fn(), startDate, + filterQuery: '', }; const MOCKED_RESPONSE = { overviewHost: { @@ -56,7 +60,7 @@ const MOCKED_RESPONSE = { jest.mock('../../containers/overview_host'); const useHostOverviewMock = useHostOverview as jest.Mock; -useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewHost', () => { const state: State = mockGlobalState; @@ -65,7 +69,10 @@ describe('OverviewHost', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); + useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -103,4 +110,24 @@ describe('OverviewHost', () => { 'Showing: 16 events' ); }); + + test('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-hosts-stats')).toBeInTheDocument(); + }); + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-hosts-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index 32585c8836cc3..1bf990b755f65 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -23,6 +23,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OwnProps { startDate: GlobalTimeArgs['from']; @@ -46,12 +47,26 @@ const OverviewHostComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewHostQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewHost, id, inspect, refetch }] = useHostOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToHost = useCallback( @@ -116,25 +131,29 @@ const OverviewHostComponent: React.FC = ({ return ( - + <>{hostPageButton} - - + {toggleStatus && ( + + )} 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 dfc144be8e5bb..2293a0380f3a8 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 @@ -21,6 +21,8 @@ import { OverviewNetwork } from '.'; import { createStore, State } from '../../../common/store'; import { useNetworkOverview } from '../../containers/overview_network'; import { SecurityPageName } from '../../../app/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/components/link_to'); const mockNavigateToApp = jest.fn(); @@ -46,6 +48,7 @@ const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; const defaultProps = { endDate, + filterQuery: '', startDate, setQuery: jest.fn(), indexNames: [], @@ -65,9 +68,10 @@ const MOCKED_RESPONSE = { }, }; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../containers/overview_network'); const useNetworkOverviewMock = useNetworkOverview as jest.Mock; -useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewNetwork', () => { const state: State = mockGlobalState; @@ -76,6 +80,9 @@ describe('OverviewNetwork', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -143,4 +150,24 @@ describe('OverviewNetwork', () => { deepLinkId: SecurityPageName.network, }); }); + + it('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-network-stats')).toBeInTheDocument(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-network-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 7607a9eac4926..ce6c065d424d4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -26,6 +26,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OverviewNetworkProps { startDate: GlobalTimeArgs['from']; @@ -48,12 +49,26 @@ const OverviewNetworkComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewNetworkQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewNetwork, id, inspect, refetch }] = useNetworkOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToNetwork = useCallback( @@ -121,26 +136,30 @@ const OverviewNetworkComponent: React.FC = ({ return ( - + <> {networkPageButton} - - + {toggleStatus && ( + + )} diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx new file mode 100644 index 0000000000000..53f07d5195c26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useHostOverview } from './index'; + +describe('useHostOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index 52b58439af0ab..b79169b1ac762 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -146,5 +146,12 @@ export const useHostOverview = ({ }; }, [overviewHostRequest, overviewHostSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewHostResponse]; }; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx new file mode 100644 index 0000000000000..64cc2e6bbd179 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkOverview } from './index'; + +describe('useNetworkOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index dd98a0ff03632..c2683b74a5b1a 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -147,5 +147,12 @@ export const useNetworkOverview = ({ }; }, [overviewNetworkRequest, overviewNetworkSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewNetworkResponse]; }; diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx index b04d9dd05f283..8c95a081b3e86 100644 --- a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx @@ -266,5 +266,13 @@ export const useRiskScore = { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, riskScoreResponse]; }; diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx new file mode 100644 index 0000000000000..6425f40016fb9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { TotalUsersKpi } from './index'; +import { useSearchStrategy } from '../../../../common/containers/use_search_strategy'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../../common/containers/use_search_strategy'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Total Users KPI', () => { + const mockUseSearchStrategy = useSearchStrategy as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + const mockSearch = jest.fn(); + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseSearchStrategy.mockReturnValue({ + result: [], + loading: false, + inspect: { + dsl: [], + response: [], + }, + search: mockSearch, + refetch: jest.fn(), + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(false); + expect(mockSearch).toHaveBeenCalled(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(true); + expect(mockSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx index 043c6b472497e..ffa5d851875ce 100644 --- a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx @@ -6,7 +6,7 @@ */ import { euiPaletteColorBlind } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users'; import { UpdateDateRange } from '../../../../common/components/charts/common'; @@ -17,6 +17,7 @@ import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/c import { kpiTotalUsersMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric'; import { kpiTotalUsersAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_area'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -60,15 +61,21 @@ const TotalUsersKpiComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(UsersQueries.kpiTotalUsers); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const { loading, result, search, refetch, inspect } = useSearchStrategy({ factoryQueryType: UsersQueries.kpiTotalUsers, initialResult: { users: 0, usersHistogram: [] }, errorMessage: i18n.ERROR_USERS_KPI, + skip: querySkip, }); useEffect(() => { - if (!skip) { + if (!querySkip) { search({ filterQuery, defaultIndex: indexNames, @@ -79,7 +86,7 @@ const TotalUsersKpiComponent: React.FC = ({ }, }); } - }, [search, from, to, filterQuery, indexNames, skip]); + }, [search, from, to, filterQuery, indexNames, querySkip]); return ( = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx index 3faa96b436de0..c0cd2e351298e 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx @@ -14,7 +14,7 @@ import { UsersType } from '../../store/model'; describe('UserRiskScoreTable', () => { const username = 'test_user_name'; - const defautProps = { + const defaultProps = { data: [ { '@timestamp': '1641902481', @@ -32,6 +32,7 @@ describe('UserRiskScoreTable', () => { isInspect: false, loading: false, loadPage: noop, + setQuerySkip: jest.fn(), severityCount: { Unknown: 0, Low: 0, @@ -46,7 +47,7 @@ describe('UserRiskScoreTable', () => { it('renders', () => { const { queryByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx index 9f782b7f28662..810525d4f1ca7 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -57,6 +57,7 @@ interface UserRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: usersModel.UsersType; @@ -74,6 +75,7 @@ const UserRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -210,6 +212,7 @@ const UserRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx new file mode 100644 index 0000000000000..98b69d531c4dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../../hosts/containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AllUsersQueryTabBody } from './all_users_query_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../hosts/containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index 6c494c9752c4f..8fa963ef179f2 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -6,12 +6,13 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAuthentications } from '../../../hosts/containers/authentications'; +import React, { useEffect, useState } from 'react'; +import { useAuthentications, ID } from '../../../hosts/containers/authentications'; import { UsersComponentsQueryProps } from './types'; import { AuthenticationTable } from '../../../hosts/components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -26,6 +27,11 @@ export const AllUsersQueryTabBody = ({ docValueFields, deleteQuery, }: UsersComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -34,7 +40,7 @@ export const AllUsersQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, // TODO Fix me // @ts-ignore @@ -55,6 +61,7 @@ export const AllUsersQueryTabBody = ({ refetch={refetch} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} docValueFields={docValueFields} indexNames={indexNames} diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..6b5ec66f864bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useUserRiskScore, useUserRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { UserRiskScoreQueryTabBody } from './user_risk_score_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseUserRiskScore = useUserRiskScore as jest.Mock; + const mockUseUserRiskScoreKpi = useUserRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUserRiskScore.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + mockUseUserRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx index a19e7803cb90f..a479788ce0f41 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { UsersComponentsQueryProps } from './types'; @@ -20,6 +20,7 @@ import { useUserRiskScore, useUserRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); @@ -43,15 +44,22 @@ export const UserRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(UserRiskScoreQueryId.USERS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useUserRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useUserRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const UserRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} From 7aa89aac3bb5f2ba972aa4349259c53845becdbb Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 23 Mar 2022 11:52:52 -0700 Subject: [PATCH 23/66] Fix typos in dev docs (#128400) --- dev_docs/contributing/standards.mdx | 2 +- dev_docs/key_concepts/kibana_platform_plugin_intro.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev_docs/contributing/standards.mdx b/dev_docs/contributing/standards.mdx index d2f31f3a4faa2..cef9199aee924 100644 --- a/dev_docs/contributing/standards.mdx +++ b/dev_docs/contributing/standards.mdx @@ -69,7 +69,7 @@ Every team should be collecting telemetry metrics on it’s public API usage. Th ### APM -Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking serveral types of transactions by default, such as `page-load`, `request`, etc. +Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking several types of transactions by default, such as `page-load`, `request`, etc. You may introduce custom transactions. Please refer to the [APM documentation](https://www.elastic.co/guide/en/apm/get-started/current/index.html) and follow these guidelines when doing so: - Use dashed syntax for transaction types and names: `my-transaction-type` and `my-transaction-name` diff --git a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx index 195e5c1f6f211..417d6e4983d4f 100644 --- a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx +++ b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx @@ -153,7 +153,7 @@ plugins to customize the Kibana experience. Examples of extension points are: - core.overlays.showModal - embeddables.registerEmbeddableFactory - uiActions.registerAction -- core.saedObjects.registerType +- core.savedObjects.registerType ## Follow up material From 55e42cec93228a447b624c2b0001696712257efe Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Wed, 23 Mar 2022 20:26:00 +0100 Subject: [PATCH 24/66] [Cases] Allow custom toast title and content in cases hooks (#128145) --- .../cases/public/common/translations.ts | 15 +- .../public/common/use_cases_toast.test.tsx | 135 +++++++++++++++--- .../cases/public/common/use_cases_toast.tsx | 87 +++++++++-- .../use_cases_add_to_existing_case_modal.tsx | 16 ++- .../use_cases_add_to_new_case_flyout.tsx | 14 +- 5 files changed, 230 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 5c349a65dd869..10005b2c87bce 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -257,13 +257,22 @@ export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.common.appro export const CASE_SUCCESS_TOAST = (title: string) => i18n.translate('xpack.cases.actions.caseSuccessToast', { + values: { title }, + defaultMessage: '{title} has been updated', + }); + +export const CASE_ALERT_SUCCESS_TOAST = (title: string) => + i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { values: { title }, defaultMessage: 'An alert has been added to "{title}"', }); -export const CASE_SUCCESS_SYNC_TEXT = i18n.translate('xpack.cases.actions.caseSuccessSyncText', { - defaultMessage: 'Alerts in this case have their status synched with the case status', -}); +export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( + 'xpack.cases.actions.caseAlertSuccessSyncText', + { + defaultMessage: 'Alerts in this case have their status synched with the case status', + } +); export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { defaultMessage: 'View Case', diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 9bd6a6675a5c1..517d1cfdd77b1 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -9,33 +9,97 @@ import { renderHook } from '@testing-library/react-hooks'; import { useToasts } from '../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../common/mock'; import { CaseToastSuccessContent, useCasesToast } from './use_cases_toast'; -import { mockCase } from '../containers/mock'; +import { alertComment, basicComment, mockCase } from '../containers/mock'; import React from 'react'; import userEvent from '@testing-library/user-event'; +import { SupportedCaseAttachment } from '../types'; jest.mock('../common/lib/kibana'); const useToastsMock = useToasts as jest.Mock; describe('Use cases toast hook', () => { + const successMock = jest.fn(); + + function validateTitle(title: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.title(el); + expect(el).toHaveTextContent(title); + } + + function validateContent(content: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.text(el); + expect(el).toHaveTextContent(content); + } + + useToastsMock.mockImplementation(() => { + return { + addSuccess: successMock, + }; + }); + + beforeEach(() => { + successMock.mockClear(); + }); + describe('Toast hook', () => { - const successMock = jest.fn(); - useToastsMock.mockImplementation(() => { - return { - addSuccess: successMock, - }; - }); - it('should create a success tost when invoked with a case', () => { + it('should create a success toast when invoked with a case', () => { const { result } = renderHook( () => { return useCasesToast(); }, { wrapper: TestProviders } ); - result.current.showSuccessAttach(mockCase); + result.current.showSuccessAttach({ + theCase: mockCase, + }); expect(successMock).toHaveBeenCalled(); }); }); + + describe('toast title', () => { + it('should create a success toast when invoked with a case and a custom title', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ theCase: mockCase, title: 'Custom title' }); + validateTitle('Custom title'); + }); + + it('should display the alert sync title when called with an alert attachment ', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateTitle('An alert has been added to "Another horrible breach!!'); + }); + + it('should display a generic title when called with a non-alert attachament', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [basicComment as SupportedCaseAttachment], + }); + validateTitle('Another horrible breach!! has been updated'); + }); + }); describe('Toast content', () => { let appMockRender: AppMockRenderer; const onViewCaseClick = jest.fn(); @@ -44,20 +108,57 @@ describe('Use cases toast hook', () => { onViewCaseClick.mockReset(); }); - it('renders a correct successfull message with synced alerts', () => { - const result = appMockRender.render( - + it('should create a success toast when invoked with a case and a custom content', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } ); - expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent( - 'Alerts in this case have their status synched with the case status' + result.current.showSuccessAttach({ theCase: mockCase, content: 'Custom content' }); + validateContent('Custom content'); + }); + + it('renders an alert-specific content when called with an alert attachment and sync on', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('Alerts in this case have their status synched with the case status'); + }); + + it('renders empty content when called with an alert attachment and sync off', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: { ...mockCase, settings: { ...mockCase.settings, syncAlerts: false } }, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('View Case'); + }); + + it('renders a correct successful message content', () => { + const result = appMockRender.render( + ); + expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent('my content'); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); - it('renders a correct successfull message with not synced alerts', () => { + it('renders a correct successful message without content', () => { const result = appMockRender.render( - + ); expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); @@ -66,7 +167,7 @@ describe('Use cases toast hook', () => { it('Calls the onViewCaseClick when clicked', () => { const result = appMockRender.render( - + ); userEvent.click(result.getByTestId('toaster-content-case-view-link')); expect(onViewCaseClick).toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index 98cc7fa1d8faa..d02f792d601cf 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -9,10 +9,16 @@ import { EuiButtonEmpty, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { Case } from '../../common'; +import { Case, CommentType } from '../../common'; import { useToasts } from '../common/lib/kibana'; import { useCaseViewNavigation } from '../common/navigation'; -import { CASE_SUCCESS_SYNC_TEXT, CASE_SUCCESS_TOAST, VIEW_CASE } from './translations'; +import { CaseAttachments } from '../types'; +import { + CASE_ALERT_SUCCESS_SYNC_TEXT, + CASE_ALERT_SUCCESS_TOAST, + CASE_SUCCESS_TOAST, + VIEW_CASE, +} from './translations'; const LINE_CLAMP = 3; const Title = styled.span` @@ -28,46 +34,101 @@ const EuiTextStyled = styled(EuiText)` `} `; +function getToastTitle({ + theCase, + title, + attachments, +}: { + theCase: Case; + title?: string; + attachments?: CaseAttachments; +}): string { + if (title !== undefined) { + return title; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert) { + return CASE_ALERT_SUCCESS_TOAST(theCase.title); + } + } + } + return CASE_SUCCESS_TOAST(theCase.title); +} + +function getToastContent({ + theCase, + content, + attachments, +}: { + theCase: Case; + content?: string; + attachments?: CaseAttachments; +}): string | undefined { + if (content !== undefined) { + return content; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert && theCase.settings.syncAlerts) { + return CASE_ALERT_SUCCESS_SYNC_TEXT; + } + } + } + return undefined; +} + export const useCasesToast = () => { const { navigateToCaseView } = useCaseViewNavigation(); const toasts = useToasts(); return { - showSuccessAttach: (theCase: Case) => { + showSuccessAttach: ({ + theCase, + attachments, + title, + content, + }: { + theCase: Case; + attachments?: CaseAttachments; + title?: string; + content?: string; + }) => { const onViewCaseClick = () => { navigateToCaseView({ detailName: theCase.id, }); }; + const renderTitle = getToastTitle({ theCase, title, attachments }); + const renderContent = getToastContent({ theCase, content, attachments }); + return toasts.addSuccess({ color: 'success', iconType: 'check', - title: toMountPoint({CASE_SUCCESS_TOAST(theCase.title)}), + title: toMountPoint({renderTitle}), text: toMountPoint( - + ), }); }, }; }; + export const CaseToastSuccessContent = ({ - syncAlerts, onViewCaseClick, + content, }: { - syncAlerts: boolean; onViewCaseClick: () => void; + content?: string; }) => { return ( <> - {syncAlerts && ( + {content !== undefined ? ( - {CASE_SUCCESS_SYNC_TEXT} + {content} - )} + ) : null} { +type AddToExistingFlyoutProps = AllCasesSelectorModalProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps) => { const createNewCaseFlyout = useCasesAddToNewCaseFlyout({ attachments: props.attachments, onClose: props.onClose, @@ -25,6 +30,8 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps return props.onRowClick(theCase); } }, + toastTitle: props.toastTitle, + toastContent: props.toastContent, }); const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -53,7 +60,12 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps closeModal(); createNewCaseFlyout.open(); } else { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); if (props.onRowClick) { props.onRowClick(theCase); } diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 5422ab9be995d..c1c0793fe2340 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -12,7 +12,12 @@ import { CasesContextStoreActionsList } from '../../cases_context/cases_context_ import { useCasesContext } from '../../cases_context/use_cases_context'; import { CreateCaseFlyoutProps } from './create_case_flyout'; -export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { +type AddToNewCaseFlyoutProps = CreateCaseFlyoutProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToNewCaseFlyout = (props: AddToNewCaseFlyoutProps) => { const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -35,7 +40,12 @@ export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { }, onSuccess: async (theCase: Case) => { if (theCase) { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); } if (props.onSuccess) { return props.onSuccess(theCase); From 506648c917e44a3941fa166832f7292804018c1e Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 23 Mar 2022 15:41:33 -0400 Subject: [PATCH 25/66] Mark `elasticsearch.serviceAccountToken` setting as GA (#128420) --- docs/setup/settings.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 2b36e1fb66185..23487f1ff3d88 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -282,7 +282,7 @@ on the {kib} index at startup. {kib} users still need to authenticate with {es}, which is proxied through the {kib} server. |[[elasticsearch-service-account-token]] `elasticsearch.serviceAccountToken:` - | beta[]. If your {es} is protected with basic authentication, this token provides the credentials + | If your {es} is protected with basic authentication, this token provides the credentials that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting is an alternative to `elasticsearch.username` and `elasticsearch.password`. From 838f3a67bf09b6de358e02ce5ff77858f9ed9b50 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Mar 2022 14:25:39 -0600 Subject: [PATCH 26/66] [Security Solution] New landing page (#127324) --- .../security_solution/common/constants.ts | 15 +- .../cypress/screens/overview.ts | 2 +- .../public/app/deep_links/index.ts | 14 ++ .../public/app/home/home_navigations.ts | 8 + .../public/app/translations.ts | 4 + .../link_to/redirect_to_overview.tsx | 3 + .../common/components/navigation/types.ts | 1 + .../index.test.tsx | 13 +- .../use_navigation_items.tsx | 1 + .../common/components/url_state/constants.ts | 1 + .../common/components/url_state/helpers.ts | 3 + .../public/hosts/pages/hosts.test.tsx | 22 ++- .../public/network/pages/network.test.tsx | 19 ++- .../components/landing_cards/index.tsx | 156 ++++++++++++++++++ .../components/landing_cards/translations.tsx | 74 +++++++++ .../components/overview_empty/index.test.tsx | 88 +++------- .../components/overview_empty/index.tsx | 37 +---- .../public/overview/images/endpoint.png | Bin 0 -> 86401 bytes .../public/overview/images/siem.png | Bin 0 -> 345549 bytes .../public/overview/images/video.svg | 9 + .../public/overview/pages/landing.tsx | 25 +++ .../public/overview/pages/overview.test.tsx | 37 ++++- .../public/overview/routes.tsx | 17 +- .../public/users/pages/users_tabs.test.tsx | 14 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 26 files changed, 447 insertions(+), 120 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/images/endpoint.png create mode 100644 x-pack/plugins/security_solution/public/overview/images/siem.png create mode 100644 x-pack/plugins/security_solution/public/overview/images/video.svg create mode 100644 x-pack/plugins/security_solution/public/overview/pages/landing.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2fd412eb357b6..cc64b7e640f1f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -92,36 +92,38 @@ export enum SecurityPageName { detectionAndResponse = 'detection_response', endpoints = 'endpoints', eventFilters = 'event_filters', - hostIsolationExceptions = 'host_isolation_exceptions', events = 'events', exceptions = 'exceptions', explore = 'explore', + hostIsolationExceptions = 'host_isolation_exceptions', hosts = 'hosts', hostsAnomalies = 'hosts-anomalies', hostsExternalAlerts = 'hosts-external_alerts', hostsRisk = 'hosts-risk', - users = 'users', - usersAnomalies = 'users-anomalies', - usersRisk = 'users-risk', investigate = 'investigate', + landing = 'get_started', network = 'network', networkAnomalies = 'network-anomalies', networkDns = 'network-dns', networkExternalAlerts = 'network-external_alerts', networkHttp = 'network-http', networkTls = 'network-tls', - timelines = 'timelines', - timelinesTemplates = 'timelines-templates', overview = 'overview', policies = 'policies', rules = 'rules', + timelines = 'timelines', + timelinesTemplates = 'timelines-templates', trustedApps = 'trusted_apps', uncommonProcesses = 'uncommon_processes', + users = 'users', + usersAnomalies = 'users-anomalies', + usersRisk = 'users-risk', } export const TIMELINES_PATH = '/timelines' as const; export const CASES_PATH = '/cases' as const; export const OVERVIEW_PATH = '/overview' as const; +export const LANDING_PATH = '/get_started' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; @@ -140,6 +142,7 @@ export const HOST_ISOLATION_EXCEPTIONS_PATH = export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const; +export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const; export const APP_DETECTION_RESPONSE_PATH = `${APP_PATH}${DETECTION_RESPONSE_PATH}` as const; export const APP_MANAGEMENT_PATH = `${APP_PATH}${MANAGEMENT_PATH}` as const; diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index e478f16e72844..42f16340e6ac6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -144,7 +144,7 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; -export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; +export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="siem-landing-page"]'; export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]'; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 144095d0aa528..efb220467c9d0 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -32,9 +32,11 @@ import { TRUSTED_APPLICATIONS, POLICIES, ENDPOINTS, + GETTING_STARTED, } from '../translations'; import { OVERVIEW_PATH, + LANDING_PATH, DETECTION_RESPONSE_PATH, ALERTS_PATH, RULES_PATH, @@ -84,6 +86,18 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], order: 9000, }, + { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + navLinkStatus: AppNavLinkStatus.visible, + features: [FEATURE.general], + keywords: [ + i18n.translate('xpack.securitySolution.search.getStarted', { + defaultMessage: 'Getting started', + }), + ], + }, { id: SecurityPageName.detectionAndResponse, title: DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 0b06d02d46464..1ae5544dbd740 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -30,6 +30,7 @@ import { SecurityPageName, APP_HOST_ISOLATION_EXCEPTIONS_PATH, APP_USERS_PATH, + APP_LANDING_PATH, } from '../../../common/constants'; export const navTabs: SecurityNav = { @@ -40,6 +41,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'overview', }, + [SecurityPageName.landing]: { + id: SecurityPageName.landing, + name: i18n.GETTING_STARTED, + href: APP_LANDING_PATH, + disabled: false, + urlKey: 'get_started', + }, [SecurityPageName.detectionAndResponse]: { id: SecurityPageName.detectionAndResponse, name: i18n.DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 2e0743de69043..f0ebb711f1f38 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -22,6 +22,10 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { defaultMessage: 'Hosts', }); +export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { + defaultMessage: 'Getting started', +}); + export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network', { defaultMessage: 'Network', }); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx index 3f34b857615fe..6a83edd7442de 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx @@ -6,6 +6,9 @@ */ import { appendSearch } from './helpers'; +import { LANDING_PATH } from '../../../../common/constants'; export const getAppOverviewUrl = (overviewPath: string, search?: string) => `${overviewPath}${appendSearch(search)}`; + +export const getAppLandingUrl = (search?: string) => `${LANDING_PATH}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0a4f12e348eff..b1903ef869d3d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -47,6 +47,7 @@ export type SecurityNavKey = | SecurityPageName.detectionAndResponse | SecurityPageName.case | SecurityPageName.endpoints + | SecurityPageName.landing | SecurityPageName.policies | SecurityPageName.eventFilters | SecurityPageName.exceptions diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index a00ea4b6bf520..601794dd25917 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -125,6 +125,16 @@ describe('useSecuritySolutionNavigation', () => { "name": "Overview", "onClick": [Function], }, + Object { + "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-get_started", + "disabled": false, + "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "get_started", + "isSelected": false, + "name": "Getting started", + "onClick": [Function], + }, ], "name": "", }, @@ -286,8 +296,7 @@ describe('useSecuritySolutionNavigation', () => { () => useSecuritySolutionNavigation(), { wrapper: TestProviders } ); - - expect(result?.current?.items?.[0].items?.[1].id).toEqual( + expect(result?.current?.items?.[0].items?.[2].id).toEqual( SecurityPageName.detectionAndResponse ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index 677632d20e718..14b007be4764d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -78,6 +78,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { name: '', items: [ navTabs[SecurityPageName.overview], + navTabs[SecurityPageName.landing], // Temporary check for detectionAndResponse while page is feature flagged ...(navTabs[SecurityPageName.detectionAndResponse] != null ? [navTabs[SecurityPageName.detectionAndResponse]] diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index d8a2db30d4a7e..3b319b810a66e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -31,6 +31,7 @@ export type UrlStateType = | 'cases' | 'detection_response' | 'exceptions' + | 'get_started' | 'host' | 'users' | 'network' 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 559dff64eec4b..e5ce8e4105cac 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 @@ -94,6 +94,9 @@ export const replaceQueryStringInLocation = ( export const getUrlType = (pageName: string): UrlStateType => { if (pageName === SecurityPageName.overview) { return 'overview'; + } + if (pageName === SecurityPageName.landing) { + return 'get_started'; } else if (pageName === SecurityPageName.hosts) { return 'host'; } else if (pageName === SecurityPageName.network) { 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 86dae3780e1ae..d82189ab1e3bb 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 @@ -25,6 +25,8 @@ import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -39,7 +41,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
), })); - +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -48,6 +50,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ...mockCasesContract(), }, @@ -79,19 +85,25 @@ const mockHistory = { }; const mockUseSourcererDataView = useSourcererDataView as jest.Mock; describe('Hosts - rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', async () => { mockUseSourcererDataView.mockReturnValue({ indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -99,14 +111,14 @@ describe('Hosts - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); test('it should render tab navigation', async () => { 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 1407bf960843e..23cd7f707dfe8 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 @@ -25,6 +25,8 @@ import { inputsActions } from '../../common/store/inputs'; import { Network } from './network'; import { NetworkRoutes } from './navigation'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -76,6 +78,7 @@ const mockProps = { }; const mockMapVisibility = jest.fn(); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -90,6 +93,7 @@ jest.mock('../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, maps: mockMapVisibility(), }, + navigateToApp: mockNavigateToApp, }, storage: { get: () => true, @@ -112,20 +116,27 @@ describe('Network page - rendering', () => { beforeAll(() => { mockMapVisibility.mockReturnValue({ show: true }); }); + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -134,7 +145,7 @@ describe('Network page - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( @@ -142,7 +153,7 @@ describe('Network page - rendering', () => { ); await waitFor(() => { - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx new file mode 100644 index 0000000000000..d8852d8603518 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiPageHeader, + EuiToolTip, +} from '@elastic/eui'; +import styled from 'styled-components'; +import * as i18n from './translations'; +import endpointPng from '../../images/endpoint.png'; +import siemPng from '../../images/siem.png'; +import videoSvg from '../../images/video.svg'; +import { ADD_DATA_PATH } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; + +const imgUrls = { + siem: siemPng, + video: videoSvg, + endpoint: endpointPng, +}; + +const StyledEuiCard = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } +`; +const StyledEuiCardTop = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } + max-width: 600px; + display: block; + margin: 20px auto 0; +`; +const StyledEuiPageHeader = styled(EuiPageHeader)` + h1 { + font-size: 18px; + } +`; + +const StyledEuiImage = styled(EuiImage)` + img { + display: block; + margin: 0 auto; + } +`; + +const StyledImgEuiCard = styled(EuiCard)` + img { + margin-top: 20px; + max-width: 400px; + } +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + background: ${({ theme }) => theme.eui.euiColorLightestShade}; + padding: 20px; + margin: -12px !important; +`; + +const ELASTIC_SECURITY_URL = `elastic.co/security`; + +export const LandingCards = memo(() => { + const { + http: { + basePath: { prepend }, + }, + } = useKibana().services; + + const tooltipContent = ( + + {ELASTIC_SECURITY_URL} + + ); + + const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]); + return ( + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + ); +}); +LandingCards.displayName = 'LandingCards'; diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx new file mode 100644 index 0000000000000..51da2e72c3bbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SIEM_HEADER = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.header', + { + defaultMessage: 'Elastic Security', + } +); + +export const SIEM_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.title', + { + defaultMessage: 'Security at the speed of Elastic', + } +); +export const SIEM_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.desc', + { + defaultMessage: + 'Elastic Security equips teams to prevent, detect, and respond to threats at cloud speed and scale — securing business operations with a unified, open platform.', + } +); +export const SIEM_CTA = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.cta', + { + defaultMessage: 'Add security integrations', + } +); +export const ENDPOINT_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.title', + { + defaultMessage: 'Endpoint security at scale', + } +); +export const ENDPOINT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.desc', + { + defaultMessage: 'Prevent, collect, detect and respond -- all with Elastic Agent.', + } +); + +export const SIEM_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.title', + { + defaultMessage: 'SIEM for the modern SOC', + } +); +export const SIEM_CARD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.desc', + { + defaultMessage: 'Detect, investigate, and respond to evolving threats', + } +); + +export const UNIFY_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.title', + { + defaultMessage: 'Unify SIEM, endpoint security, and cloud security', + } +); +export const UNIFY_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.desc', + { + defaultMessage: + 'Elastic Security modernizes security operations — enabling analytics across years of data, automating key processes, and bringing native endpoint security to every host.', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 36ecc3371c056..db157e9fc7135 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -6,71 +6,35 @@ */ import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { OverviewEmpty } from '.'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; - -const endpointPackageVersion = '0.19.1'; - -jest.mock('../../../common/lib/kibana'); -jest.mock('../../../management/pages/endpoint_hosts/view/hooks', () => ({ - useIngestUrl: jest - .fn() - .mockReturnValue({ appId: 'ingestAppId', appPath: 'ingestPath', url: 'ingestUrl' }), - useEndpointSelector: jest.fn().mockReturnValue({ endpointPackageVersion }), -})); - -jest.mock('../../../common/components/user_privileges', () => ({ - useUserPrivileges: jest - .fn() - .mockReturnValue({ endpointPrivileges: { loading: false, canAccessFleet: true } }), -})); - -jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ - useNavigateToAppEventHandler: jest.fn(), -})); - -describe('OverviewEmpty', () => { - describe('When isIngestEnabled = true', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - wrapper = shallow(); - }); - - afterAll(() => { - (useUserPrivileges as jest.Mock).mockReset(); - }); - - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; + +const mockNavigateToApp = jest.fn(); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, }, - }); - }); - }); - - describe('When isIngestEnabled = false', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - endpointPrivileges: { loading: false, canAccessFleet: false }, - }); - wrapper = shallow(); - }); + }, + }), + }; +}); - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', - }, - }); +describe('Redirect to landing page', () => { + it('render with correct actions ', () => { + shallow(); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 023d010ec9a9b..91395aa21486f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -6,39 +6,18 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana'; -import { SOLUTION_NAME } from '../../../../public/common/translations'; - -import { - NoDataPage, - NoDataPageActionsProps, -} from '../../../../../../../src/plugins/kibana_react/public'; +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; const OverviewEmptyComponent: React.FC = () => { - const { docLinks } = useKibana().services; - - const agentAction: NoDataPageActionsProps = { - elasticAgent: { - category: 'security', - title: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.title', { - defaultMessage: 'Add a Security integration', - }), - description: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.description', { - defaultMessage: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - }), - }, - }; + const { navigateToApp } = useKibana().services.application; - return ( - - ); + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); + return null; }; OverviewEmptyComponent.displayName = 'OverviewEmptyComponent'; diff --git a/x-pack/plugins/security_solution/public/overview/images/endpoint.png b/x-pack/plugins/security_solution/public/overview/images/endpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..073318f891fcf3e828ed3148a0d368c724825001 GIT binary patch literal 86401 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

&|faKRtvI|3faPilcT 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

W058t?hEBrj?ya{= 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 literal 0 HcmV?d00001 diff --git a/x-pack/plugins/security_solution/public/overview/images/siem.png b/x-pack/plugins/security_solution/public/overview/images/siem.png new file mode 100644 index 0000000000000000000000000000000000000000..e5d6bee86a6cf90883065d5b5794b0f261ef2af7 GIT binary patch literal 345549 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Ă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

1g36m(;5aplTVvH7?(eAkO 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­emE;@dU{)dQ(OtbyFhm?$H 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

sPYFb%?qj9u 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¤`WifSX_us`U3IaG}o&x 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 Ymj1c5(1$asK&e!bRJ zTsET{ACXDAe~uLF(9N?RX;VPfcO1uVBP+i+0$4Aej4dR7{JaUZonM6zpf@OkW+;e< zhP&oA1y{4TtPWxD5GH)|VuH10PsOg z8={X$cPw1(f3P@#T!8!o4p_*s17z?KVVV%tvLV4FjXaJZgc0~|j~5DrTO^hwL@bi> zOU6k6B6Jq%F-vAByq10-9MU_NqZ3p*tlt-g6yk`4!+Wl ze(cQ6eLBT_m*+3}@pCLloEAXtvkGcRo=G7h!czm)Dql?SCyk7S?fqTc06h1KN*hC{ z_L7Fk;%Nqkg53?`@2}S)AyMPn(;Dz3Y}ySa$<|#L=tPg*T^=5x{C?ec50Rbw;Y$!& z-zDbK9){a;9Zs}=;<#%}UyEOxv2*<_%Uer(1~Ps!el^$(Fe(fl14t$zfy31l042cj z3CIlSS13~hXA3}0iEw6m7~er*jP)oQ(#Omo!Gn@OX5&@2#R_>LIY9G3rDO$0s^B9t zc0&1~9Wfs=KkHXC1JZya?50H)JT*x2ADcR@n{Mi=y|Ed26~;rh`(C@LA&b5uKR>9h zZYFwbysHCq9EGV7nuZF3g$E_corx05d2O9j9Q4?H58-cHHJwNFwJ?g{H zoE1Mb^D}8?Mt;jlyobv2hNtq4^W%mpYY!*4^1Qj5Tvon<#&mQlJUss3f)Y*I-}W zbWe**SDDKTRC$NZ{@BUCB!0eYJ8HqOe#zX^6enEco~jJ&0{^`KPgA?F&3=k4QM&8- zx20ZU8h)^=ajkE+OknxKL3C8&fY#fOo&Ukv2fP9lI>7BVvmh1HXvkbX{sAbkFyYZb zqyVMQuHNlkW9PpZkcMbXpS&oEK}M>_fXlsUnsddHZ&il=2JG=1T(``{Z184A=bEr2E~3 zza4I^r1uveG#`YmqJ{77i_%yOT5KhL3IP@0ubT{)5`N|vOPmFzCU1Poa*A1Q_IRDs zn~y#D`VK{7nA!4%g7ocYbLXwOE@l(lIr1-ZZlp9cKKJMtF1ZTAVA%wVuF zWum9-3G~F}m#zuzKU`Y1`+NDPbKbAsT<)L|f_57+QeOTHFm44EU-j^DWkJ3CQwRdz^)vz$ zB#tuTqlURhlTd7f+8WFw`|w5@x-x}EJk@X}8%DWQAW6cPf~8rlFCoY@ZpdSP+L110 zeB;B{@fU;*W}(4dbwQKzT=F7*zOEv1DcGq5Xkg&Zda@f%(vqPl^WEJ>s}g#2UVMG; zyv8;UOpOZy$7P%x^Oug9324nxODEqw^ai+x`6rEovC*I#g1&P+C}cf0?E%6HCHK7f z=qlE)M<;vLJ-$z1X!@$@gI>D&4iuI8^EfcO9jJETs?nd?XB}z&l%7*|p!t~wd6LBr zGG*rTy!>=5VWqtJyCT=C2ENeQ_=Ov;QE%jVPpa56Z00RxoEzT|z03O{|4a6`ugjYN zimoXB2@C$7E#-PhOv<6L9&!Dn2Jw;@`8BgXtH?qC^Iymi;n^kJ+G{hlo2URJB8eR^Yz=(7)N#_t&w3>=J{B^orubJ- z@dHtj$UHv+twv3weB`Hju%e+BDANMEWMKmI1vHSng%Z?Bfx%`^aTTXmtI$7z%MAqC zIY&LO{VHr*b@fyY^WA1@`j_uC35Vy6!%j23=epQmz>5fneX+~Zh&^)#dbvO%gN$l% z$by9c0ZarZgkCtFK==*noG&){g)nMnbOw{bP&CM+}A z=N=OevQMN=u}E;l$L*)ZnrQo=KewMk@?p^Ld!4MOz#KpgYQG)}3^szxr6Anl{f4O+ zU{qE2xYP1wZuA{(*_%hJ!9frTyZfJbK)nT~{Z;p&G~Q>afomCoelE55BuSkoA=d{q zf{Y%EbmE{;)0uDb5u_uw;C}(aJ3gs>{KgY)T+TDk)`{O;X*>|lq9!TTWgQB+{OE9_ zGdkMZAn|duL8+HlL`#=;TGs#}dr4)Tr+CmZ6N1ElST>wl6DQlB%iPQJ%KlOxw-^odQ=7jtNNpKqj~wt0~oYjin!I+rmJZDntA!_eE%N)t7n}$wI(A1rp3^#3scPx1JQAm0wi&26 zyKOOy=?dN^891@LpC3B%W|1Rd{p*Yvrt<2?3a{&l^It063!TidW--nOsTHWkTwU>* z7w*q3BK&h)+lw@r8^3sc=ao<}sK{0l6igxL3QgH|vPm4(K9SIyDzY@gKd+bIEN&XV z>{sA{-;4dZ{;MpjCbVR`g0&ozxKY+dDm#hQ@G#98Iw+F^X3a^oBE#EKoSB)>n#poGS+md;aIAM|mqR z>v~{R#5K9QP~3=X=*cN8OocFtzyS+vI8dhkfZPCemU`~;d1LoS;GA8DDWRctk3m?~ zI9mqd&`S_E_AU1__Muh6>-NpmvzB4!<)Z%9VuXnjzL+*<#kd}oRxMHui9}3RUu5E0v$i1Ir=vK?A<>~ z4OmRQ0%)(WB*>x&t6>&!l!+ZwFQBmLHgXINo>d#rt`1rd2RKqd@2SK?jeMWqbk*^l zdBV>c{DOW)%#!+V(oVbl%S|VIPFn>ZzpJ60#SG50$0=_JA;|?j8w%n&(0{C1*JBLr zuSR~9Vtikki9n!;#^2A5#5%^Abn~7LQiG;&afhwxz7juV`9LnV) zb`PlI*rans}!V4%O_vQC_Lu)pSrIS7_tu4F|P*l z2O0n9xyhgWNAa=FA5GnC>Wr^P@C`ItpyQdccZ*9| zF^^QC3?TOgs0xlX9(AHrspcaBIx4DpHz>5C{*;ei3I~~n#8>P_q;bx8wO#awT(uK+ zFa%BlDGmzIFV+Jk2_jO#*vvQMpb7uvUeUg^TIJ@>o7kZuxYButA>6D?_03U{4Wv)` zYz+MTikCuQUG91&Al2%&Km#800rUiDe{KKp49vte;#1uK#VdUnsGv+uQ*DJ8Xd0JP zw0W(3#99E=^2uE|ilAwIHw$iu^6fs>!INC)CqIpIdLqTCkbwuAPM3xX5TvoEV9;@w zZ3wpSr`8j}*tz}!iwWGgQrp8lC!2HHsce7w|G~huvDN;u=3@ZL_yC>)NX7FZ)55i~ zRVV$V43Loq$&{J^%>$^1)Qsh1cg?PV8iEAD%wP|SPDFE8*=L4cOp!`F7WwBnJaN+M z_kRM^sKsz!E3Um~sA@l-2y%SLhC!ft)(5}|Sl70(z-~K>0E=f)@QzRlGFiX@IF%b5 z9P7IWllmX@xGY@p8vC;W^|83LnNG-`9P6EVHf8OgPRl#kfua06nHv<(Ia)(BkpHwNtJTVM>o%(j3?WN zkBl_1Zru3XFVNc}-s`a=V6LBZq(grWa>75FC;|jhQ2@EndbzS)g#{Ug4AWQWXGTEn zM*vF-4<;y$Rk5eg6S2fLYz~GkmW(7i+ACbQ_`7?{YU(T1p4X|VJw8YJMim(H9UBD1 zl_vpJ^RlH-A3mnh77;x)r;j6%A4e-WqIx4<*~ zf9Y{9JQIGgz9z4Fh?0=9D0vGU;!xV=b|=XQL8<~-AG%D1PvcMaK<&WZJmYpIRPjFS z8h`r|ML@!FA3cxSF+fI83X|TRcANn&|H0SD7f`vKcNHf_=mKQ?V|Fuup`3v>Sr3Qn zl0B4$&P&G2_D6e1I?mKD#YgP-sVeFn+%K8ASWiIijsR7^2AZKG3Z@P~^{~hC4Y28g zlp5!aHg=D#7FTlxaiC=|-rEgI4{p~VgMXIOZj41UQH{LUO=T%+1iI1*MnPc^jDtn? z1v=Ni8gCn(hw2%~*lj@_B0zJT)DHX_3=Zcsg}}DO`eKP1U(FxU%t<7yLtVlWzn%js z2SqU0$Y4mI1dUf6bMm=t5?&ft;1)y)DJ*8YDlr&qHDHRY5Tf(@I4Dmaw^0z2cR5-t zppDXaL*nt`Yt#Gedu#{-^g8spW2-8%PuX$wwI7Tt#H9DeD42Y5}&E z?MyxGvlHbp`g3D-918_P^<{Xs(!cVE*${fnw`J<^5b@L2P+9cykUVOBFKAVJizuWy zAVM7zDF*P5KZw>~vXhfNV|d=qY|~@)<|TG(nK&$y@?;4qj5(m(xLy7>EZV`=5(G|2 zZVwHk?%RaK!~wQIV~Iq9>a}Ie(lUm8S>&#groiuk{|NB^{djlfeC`)QZOdVHjk-!U zck(ncnm`Y;0LJ|O{^`B*_{gUF#SmF2Qr?E}M(9&gR>uG2ox;1+%J0Vz7}STCcbPka znRK}6`F7^^|K1ieu|jTAl1yEqGlkSAy*(smDKdj$K>|f=*(uw;V=ai{$k>7YVn$rhd zH12q>p%V>rwP4%_+r+9CbnbeBxj=DMRhV#?|J$rAP7fECqJwrER~^Zmann zJwK(xHOIAbOLt7mkG>6#>@Fp7YtjAXTMJuMi%OM6@>TPekv0ZG-tnl)jwpt2>qdKS zi50^xvU~eGL$zIgJ5v7Gb67Vw=>xwVl2wLrY_1){tpmwd+c!FDbUZe}D095(u+HG1 z4ht<9j9ntu`jyC6OAr;1dl(}({gbvCYS z3Tvjf?w&yoIvEb{FM>oX2XrZk2a~Lw%LnbtbhvTGO(<}pkA;uHrOp2Y=b1&=RU+ek3ubue%16Z2Yz7ooX-}Re2=Zs zQ8W^^H-$eU6~W9$NBkkQgn)YzrjX_0&>S3OD?rNjB_1xA-OujLH{SN^Qk44p<5Q2e zWD7c3d9ZeOLJ$povS(gbv{N+XLfk^9rh~L$KzDt2CUF8hFTM^9#lxg#DUGi%JkZ8_ zdE1-B0Cng@-jEy9W-1ixk2xHHd3EJ_Ty6HJ$WLoO+B!VcRbwMx*;=e%r+8L}&r|Id)BUsVW_vo$Q~poAcWq`uNU^&Ou3r4qjQhFQE!O!Zoq&&yXbNPTP>EI;1RVDGEZ zgPL`l)T`GbyB)6k(#$Kf;FXu0ZkG_t#NyD_3H_`BQ;Tc+7{)Z!H{-tU&hoD&E+wt5 z4A8vyf#==tarykI29S^&d-0d{@rR`mO$x)%Pt>@V zJs0Y=R(PF7tXH>dZ0AG?{qK8RRz->^-D_5TgY}b6j1jBnZtL2kP&TKJk7B$dg?_aE z7T;-Q5$zA7pVZz&`?+f2yS}#To?hM)oQ+s^SwzpqZw>Va*6J9^-LX&6A}M4cal;^- z{QFNtmY0{?dNfk2va(K~-WvpRd<-7(+vycole~nvb+o9l?;FlxFJUBycRKdZ?0&b> z)je)9FZa>>Zf1p0(D9s#iYuaAM8`p=)yRMuL+u&-@(~W*){2L2j711O=q4FNE~EQf zdwVF-$Fc^-hwoCo7l5t%!2O%7+Qyvdo2mWZ1yGG1Pgk%zdyTeq=rfFDtLG#?`}*Op zZP-IhK+S^a;oJHfH1k)GoB-PJRQlX$PGyl5qnri<#H|&<@+$_6b-^mm&fD7>XY?Rb zPnFvvtEs7}kFhVYL>f0Y3$mSImRMK$J7{&$LS8L?J_{ls%o!o0;K9Mcx8Om8RL2V0 zAPD@e>-zLraPpd*p03Y1>*KaQ9b#}Uzm3Z1_RGRfv)UMV1MuM)`VDpNZ@a?SrKQi) zUps09hYZ#~HC!!etioedP;PG_y69_fULv$I>GW~)<9@Mo(ni|U#$Nxx0NI?}ymr&* zX6pXac(g(LS8tKsKKlEz{SP}PD%E7|_6$}}9L9P+Em;?BYDsnL_M|e({Fpy3nVeMb zraP55H7OKGZ)Nc%?sP={wf)&jnAwfTxEz!`=4o|BaBU>gx@T`tv`Rne)duvNW@G)p|NBe{K+wa&rIMlSP+Io4NMe?eumc8f^ zE^cm4pSzY)bPXO5+wW>dTEf8s=agK8jNS)-NfJOQEpS_@&k+8Ybu11Kwl2B`0MO>T z^hFm^!N`9nNWw{#ybl~q_3_ULxz0faee8-CSfc@awxhItE1(1hIIo!l@O;Y3#5%TN z#{03HIRY)NXjv>v1@Jg_t1xAqi;L@En)kKURRaTqp#5Wr8dvn!+mbV5j|Z+KUZd>* z5qVxwQG-ZKEgoWRZGG^uw}5)<5vsd)a?ieeWWf~w^>W5}g3!yHTVqsfSKp;3vCeG= z(%myND4V?7JeB8E+Ic8UBIhTc6>%e5GLt$nu`8UyMx`!$hO^iOv5Su1coFudh87qv~bFw*D4m z+g~k9nmPoOZJL>9Clslg1Dj$!jq#y&e($VE*7@$RQ0i4kJc!n^?p>1|{Hn00>6Kq5&XYVz00cqHZ<^nMBK$}Y;1Rd5Z}0)5V9T^lnjGV=OQE?IUPe*x`m*~+4e{@ zK2S`puPW-Kad&fQYFuI>0rHV8&(F`VP&NG|qMNQhxwqGk|9XDjqHkm*8oYjqq=I?J zDZ0kbQ_WKhCU9$-4X)^>nr2tc zS(6ank9*oitr3d{Gp>`KcdyF^^WNPeFnq|wq%85{d_#V8i&sBeU=)%$;B&&QxTNF} ziaomJXTWQrOv{|G)n^8hwW(@Z=NkQ5^2VBAO~*EnW~_NO2C@tKdGPK1wO>b z5>1V;pyeGsJ?I@nzlxt9=wEEwX3g+B>*gz{b)D1s$|&7%PK{c zcY2J3km=dTmX$ z_`k~HVjh^&<~h{?;{pI!epF50sVkocSIWn5EZEuFG9sg&M8(f`)8n0McY^<2k|T!S zM5?EzYE~UBjrPBOeI_ii-tkgACq5u>x+R`{aTc(~e4FF%El*lW_ORFi#*70&5sYGr zDrx)*6*~S&n_E2nnJP5{FxP|?(?~hhI9p%*$swCE6rO%WoP?L^f196oRZE{GM7zJ2 z{7>Y-A{%~HaQRyMXHv9azn+cYcZzr1R$@+=?LhIh6`D_J1d-<9#8$8rZ9aeg%#l|_ zzE=9|Rn#T3naG)i#4w_{{n63tKz~xw{CfHXl|>b36ZkbU(u)a?zFieToTaiC85P^u z@_xXPFWcx=1GZEc_lz0-ZB*<;0E$5pzQH}-OLQn#SLlT?EYbKpxZ!&9pBA9|DZcT8 zCL~!SV>bkJB#d3NyqxTd1`2@bidSy% z1f5)uxH_wv*|l7TbANwUY9`o2%UA9^EcU%LD1E6#6B zz*IR@AXcgCX+iPM1;znIZ?Ld)ba#L1cBbA^5Em}+eBfWSx5Ht;)ovXx64NpBI5>RQ zlgrwUi|c+A>|X5EvF`3jEU|G@JM3Ui^3x#rxXWALI5u{Pa$;+I{CN{In!+x~o8@?d z3ri>0eJ%Nq;Lk-&Ow1g}G=H84g1)Vt-7~hp%!ylr{37Dw%-q+qzvgk1v0h1H^f0lb zz8zjsUj7DrSF!!9Qv`XFuk0^pXyhu$vY@}cZ0OYbl)rtDf&Mu&Huj;npoi1R-pT0- z0J$jf6|@(8$O3$)8CnD$X%xsP+-z+}H(|6_GACQnC#mQDRCAH?Je%*`?m0)m2h2}@ zDdl!@f^Sn(QANhc$+ybBo9#4^{>W0l zIt?phAdC+$MZSj`@!{41R%^ll`SrBmDYY6B1@Qok zbW?P)_S$rcG$=qXUoS`3ut8*zb)93_c5BOGwaIvV@sx3F)0212rESKCP2b=Qmtn9O zTl2LYy!{VA-|{g)}&Uqt!Dopta^BAtad4kL8z-Xaw(+wyJ}c zs<&XEH%o<>3(!C&!4RL2pg`9^`Z_+>)6M3>fH zSObqT`>CDdnSBfWjP6e=snhEsjbdcO@X8J>+@seNWG-8`oIU-IBX@4;4Jb@SdzhSu zttGpj;(BgwXjT&*yLdbu>Q&Rer^Gg&g(zK8gmgb~@?DIuAEtG?^UL&lN%ZQ$!Jzp= zI|-%4i4DDa;~5VIIyxDhnYe)_hS}XOgF0sYvL#CNKX`v$aheK6@%Ed230*6N>f$-h z4$;X&^y2P_rCRRf?C)Ql*?p=MYiUoOREkilTb7z#+iTsI)VMY=-1$iPZ(h`K2|_*U zrhaEG0)XXKou@?f zPI4Oeen%#mhk*U8Ht#y%} z{(0$ymIgft<+5E0W-r6R(Q~C)KYNnF+TjHz+mUz=rW@sNkj}OD5pT%vQ8cKblgZUK*jAux=`e+}g$*`!NLiA84leYJ1~0N#Tl zVg$3}@#H6`heLiWZ-0Gi2G9=dB-{<;&%EVlRA)Q!7Y2HgyZ$(B;uU3&p{@df@)^Hd z1PBzD5Je!2p+laOs<@(J^Kf}-sr4v{z<>SlM}tBD=t2L3<;T5bRKSPOF)+|FiV;|s z=)HrZZhL#XGO%wmysJws;j$TQhvIXD86pUTHAwF6zqy$4Xk2(J``Anaf{BoVg<;5W z!eE!IAy5H?tbtNQV;@4BJd#s_o<;&D21RsVY7d19& zTP|+KJhwC(|I{!!)m^H3u&)x@)YQcM@b`;SlycFv8MdclpE@p-zC^dpwQ#J98n7}P zN~HB(Nc(?Cz!(Nv!E5!WQ^aQ-TMpFB_Jm% z_x7v#z%2h5=9Jpe-OcU|`XS(3Mtd8|!2uImkcz=r5X8H5(7y+Hh+ z>)4$T-g20v+!Mt^M#JWj96PQb-2v^XymLYVD7vCoHXXFPKaF7Q%vquFm`29CKYQBF zO6XqBQ1L;&6Dt0EzoRvqHq+Aby4PfwzW>fTpGAd5jDzSy>I|zN! zN@q?wC0ohA`@-v#?WMdQHh$KfxF2bc7Tj!V;%N5Pe}x0=e`;L};|2qxe*P$mJHHFA zRuO35yzNbVu?4PEHM{pKevtTC3Nu9(GAPlH#y;ct-Dt%&z%2h!I5bPNAHTXLrpcYZ z@9_3`Zkutx5aZvU(Nn8$Xqyu-uWnyX4B)N*tzg0^rUt_Z8Z8C+#R)GfsSOSf(`ej6 zwCiA2VlK4dLGP9%*m1|0=FbM?KGl9f+p_6CR0K0lBA=QLwO0C189}Y1O+Cc%))6ii(x*-ZgQOAxeXjn||xDE#gm^D;?tegT-{ z_X>>iS~}Kse23@Yh5!CfHMOEWjh%9OaF5BE-;hJGWWg2qa6Ed*-w~+z?v9m*RS~2d z=7O=>DZ0AZy&+o}O~yDG`NXMy?zPgNXelbID@7IL zK*O(&1q=EV+JK!R==|+)Xh}&-UcDe4{~ne!-)_C5X!ohY;$$jVci$#6?y<>L=u^f! zX5CeHwH6?Ih$y9v>gp=WQOme?O-gYQ`-8}cu zD@%w%kKwKeWst8M#jDJ#XU+I|Jj2rQ!my)g!ID22v5;~ia874tEp2QDR$alD{Lz26vhK9q!bCob ze(&yO9$y8HU@XZXI04RZ&<2QO#!%c$fK?f|EBc1D`eN zlv`G2;U**>!5mSQb_|hC3SUl4O$V79Bsnk+Q~aHE%E{!B<-0`#-7{Cn+}Hbl7f}xJ zx(Z7E3&xD2vnxL@H2Y+zo(uQfe1?~^7D#)I`e#Y=58$TeH{oQ)!JQJltqY`2`Kh10 zJB`>iJYrN*BQlQmMRKg4aadpCY_ztaWW0M4j98(q=?VhJ!GNXU{i~6d>FRmtCS#MY z!%qv2`TRcancqk5Qu`|H1KAjGm>{xj0#OIJnFT5*gK^eJwg_;uq0gWcZchqRObwv&LF-!TXlh*n(V5<(eofb(pVOs77mZM8c?eh&;(RjwOjc_vX(S#JI>W7a(jT#}jqYEpk zWzM)Dh68zN;SQrSP9T=W)nfnF4;<#a>TG0{G?!db`NHm-`hHWG3fA?fL4ce}9a$#I zh|f*fEA% zzI>N5Wv9(BR8vmbWx$gI(F6&NT{p=qqJ9|RmJ!*xyd9p(UW~vHPQV4VRy;u&I)aT? zWALh3VfI=h&?95E{><{VS1w|x_W?!(W%8Lfwa6pCnB|Sd=?4Uqtlp(Yq_U$giO~`J zo}#AQ;^K|c$ne$3n3^rQU1?j{XNzzF}ZKmM)m@ z;r=Qh^E4P&j59z7Fl?qK;1Ih{iRwkR0Kb{s`?O%Ry}QO-;QU%tDtiN$ZWhH4fdYtu zp|Y1RNrp<^)VK~E#dR$5;~lfKA+oTe7vc5r39y`N+n}OQuK1-FgarZov$pgJ+mO*O zD<%)79nY)nnr&!FNKH?%0xxX?-GVr7CAGV>ohRhXTwHovQaqn|*S^%%>FXN-t->wQ z%1{1-uRQ)Jwc6 zl?WLSBMc4=#lXGF)q2A$KWb^#&?gdHN%MWRGFe~U(jOhZ=6Vqp5VJgf+T~?GO9#)d z74eMVwUMGK53hx3OFH$hL^X%@rQr3z43hnSr8Ci5$tMop7qJJ9sJ9;^LLziOXJ;#= ztDi55+FH$C7MgVw1wQhC;f>?*yldw=A2hpm?2UB&W42E2xZZPpN+?*3@ZdT$#TJLG zdtuurw6Gwp&~xwtrzn7t*7Es%Z=B0GQR_k|qtMV$#j25L;6X4uVc1_=`(A3zZuS~pS%<$l@RfloFStMQ-MMoItaH}(HZ4HH0N@a(ihv;8j8nzL z!YYF)OX4UDUA94$AhH4jWg!{ZTZO3(@T>60cVHA1jl6~W(2OKj#5A88{+Wt~*T4b4 zx>z*QY?kQ70-ppg00<=_D$3W9C<&tMr=S3mINVUffjKldi{?u18gNid|NeavzSB2I zJUqAHvr0gtgByk+jD<<3Gn;$_$P}t!<|K%<|9sELq+>6NN$% z?k>!AUxT|)?!MWaW3y&!EogNafj9i}I2Nu*xGW%FD8n93V5p>pOq_*f00nU{*XRE* zED8VSHi}?&-JFZO8FhgB^=7Wj4Ul?FL-+kF9Df2*8e7N1INW`NTd?h@U+(&7)&cGd z?$gS>4`~qG!1Kfx5(|!I5zKhANHu)j(P_Vlq4|}%Aj&N`6JWsDBVoGJQ=XWE61Xmy zDezKI9tKs|2*%oR!60O|9vxh&wpR;ftwy5?{RVjaoz_Jrux~u~7mJ1)3@Y4B2IDHP z25#9FzUaE-)j80%D=7Sbs=D@gDDyQwDvMnmwJLNWxzu)#ZH6Hu$*js{ccUR049Ur0 zWZYtCwM9aa+{bP!x+s?=xinKJW>PU}ave;$#F&to80Pk#$2n*1=lnH)%=^x~@AE$I z@Ao|4=leY0x$*HAki)ZwDI?Kq@XuvlnZ^za7cw4%G8OLZbp!?)lP5tYO3&vNiekhxj;)0ErV4#xu z-0e3~sO&IxRI<7^BksFBM8+B_J-;G64DUC8Zxr5+Ug&LI@4hn%y9q+CSLQh?xzBty zqr$?&{X9B9*+2=xGpaQiz5|Exhk67M1nLcyuy}HR(b?xE1 z-AQ6E!80FJcSz@qLGo}9$Y}PH`wNLZ-M#j?$!tn6_xo&%^j)ZupouICcYM9&vuOyD zPFw>`OX81iU?+=>eILTMd8z@T9%fM!l>hKE?fm((cZ>qm+z;rvPgu}Cu#y@!YJNoz zeLHm9?BOr*qj^JWo`Ig{K2<7Ma6f-o<^37$N7x4@@|F!>lu><`>EBUvC5S>^weX-8C&CXBsa?%+rzp zbb%fUh>>i6bs~3|q6heeC|OhTnv+v{my#M!+0YRVZh=GOViO!lOB5Cs_CN*&Ma{_w z>`it^n74l_tzE^xwIrc7S7S$iX=HvW%Or)K1F2P(KMEG{I5UC2Bvk{8H$s zedM(}^@u3%c+4i6a$)S~#4;_7C(fdS9x+z|TO9g!HB;mLcrIv4E$V&shSY(3Sz21o zG{K_%)WipC35f#y2s)i~UnZ_U+EecAI&kj3c32<@GQ*n*-}`Lo6+|=(THz^#V%XZ` z-9h&DslV8&%WUl8u5NgFkr*5wg`5OW2A73pO-t)9CzAzx1eUF~+h{We^KDv3)DW9a^ zAen=Er*rBYbWm~wl@hMt2j~G0bF859b|?TYyWr3FvDXx9W@DZI}3tv`QlM+baWVI6+t#v+~!ze zkNLzC-M7tTm^t%@(C3yM85|Nkg2>;PcKl~ zbu4U53?&!;9sg<@h%cF;p6VL0beWZ1*{u`RHt2i+ino8PP5s&31p?vZ&;M)Df$ zskiUFeg?-sw|DP#0tB1ds{K>YAQ7g1Z`4Q6MVSXR$%An6%>=?4?Z_F6j zbfL%OT*)2ntuPL4nEk|Xe8!cda-re48Sm5x3bj1L(f;7^lLxx2UhHrl>XQ1JTP|gk zI89TD))v41EuD3SRyl1rVmGlCKUJ)g`+T zQoNFkh0?Ah!^G!~^&Tgr+r2R}HIczezS_$#cXr-Gu8`3J(RGR`5}d?{OBmJJQEhFS zM0?c!ky(Po?Jb(VKZgJEPxR}$j?1Fq!RM*ZjFik)ElgYOCHmMQUKTn)c3!SHQA

    M@46n@D_MZK*gpO8HD;GOpUHV7A z`8MXf8(G=juqARgWp+5i>~{vK z?J-_wre0}qLvvIrHJ+$4rkwie(RUsS*Z7dPZ6wiyqe}f@%E|%ump=u)eRKHC&e{~@ z{KsY^>_ABYF~xam?KnG*^kC4SgKtX1*;hOrj=%^49_XVx{;|k+-!~vPn>17!bQ)FW zU1rON_8qu5pHSF-`KrbY@|aHxJD-fC$yJx^DRr0CppDs=)y*ST5W(7_}>zg&gYkZ1Dcxn!T + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/overview/pages/landing.tsx b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx new file mode 100644 index 0000000000000..0554f1f51c28a --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { SecurityPageName } from '../../../common/constants'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; +import { LandingCards } from '../components/landing_cards'; + +export const LandingPage = memo(() => { + return ( + <> + + + + + + ); +}); + +LandingPage.displayName = 'LandingPage'; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index da36e19d20a55..e5be86a1c9f91 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -27,8 +27,30 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime import { initialUserPrivilegesState } from '../../common/components/user_privileges/user_privileges_context'; import { EndpointPrivileges } from '../../../common/endpoint/types'; import { useHostRiskScore } from '../../risk_score/containers'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; +import { mockCasesContract } from '../../../../cases/public/mocks'; -jest.mock('../../common/lib/kibana'); +const mockNavigateToApp = jest.fn(); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, + cases: { + ...mockCasesContract(), + }, + }, + }), + }; +}); jest.mock('../../common/containers/source'); jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/containers/use_global_time', () => ({ @@ -129,6 +151,9 @@ describe('Overview', () => { }); describe('rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], @@ -146,7 +171,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); wrapper.unmount(); }); @@ -279,14 +304,18 @@ describe('Overview', () => { }); it('renders the Setup Instructions text', () => { - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/routes.tsx b/x-pack/plugins/security_solution/public/overview/routes.tsx index c4fc3a6678c51..b4aa19e1e9bc1 100644 --- a/x-pack/plugins/security_solution/public/overview/routes.tsx +++ b/x-pack/plugins/security_solution/public/overview/routes.tsx @@ -7,9 +7,15 @@ import React from 'react'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; -import { OVERVIEW_PATH, DETECTION_RESPONSE_PATH, SecurityPageName } from '../../common/constants'; +import { + LANDING_PATH, + OVERVIEW_PATH, + DETECTION_RESPONSE_PATH, + SecurityPageName, +} from '../../common/constants'; import { SecuritySubPluginRoutes } from '../app/types'; +import { LandingPage } from './pages/landing'; import { StatefulOverview } from './pages/overview'; import { DetectionResponse } from './pages/detection_response'; @@ -24,6 +30,11 @@ const DetectionResponseRoutes = () => ( ); +const LandingRoutes = () => ( + + + +); export const routes: SecuritySubPluginRoutes = [ { @@ -34,4 +45,8 @@ export const routes: SecuritySubPluginRoutes = [ path: DETECTION_RESPONSE_PATH, render: DetectionResponseRoutes, }, + { + path: LANDING_PATH, + render: LandingRoutes, + }, ]; diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx index 41cb19e48e94d..e3807f359a0ff 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx @@ -15,6 +15,8 @@ import { SecuritySolutionTabNavigation } from '../../common/components/navigatio import { Users } from './users'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContext } from '../../../../cases/public/mocks/mock_cases_context'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/components/search_bar', () => ({ @@ -26,6 +28,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
    ), })); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -34,6 +37,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ui: { getCasesContext: jest.fn().mockReturnValue(mockCasesContext), @@ -71,14 +78,17 @@ describe('Users - rendering', () => { indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it should render tab navigation', async () => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f1ab772dbb243..2395df6d2d901 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25211,8 +25211,6 @@ "xpack.securitySolution.pages.common.solutionName": "セキュリティ", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "{ conflicts } {conflicts, plural, other {アラート}}を更新できませんでした。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } {updated, plural, other {アラート}}が正常に更新されましたが、{ conflicts }は更新できませんでした。\n { conflicts, plural, other {}}すでに修正されています。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "Elasticエージェントを使用して、セキュリティイベントを収集し、エンドポイントを脅威から保護してください。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "セキュリティ統合を追加", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "ページが見つかりません", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "ページごとの行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "表示中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 51c4915baab29..6d4465ae16487 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25241,8 +25241,6 @@ "xpack.securitySolution.pages.common.solutionName": "安全", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "无法更新{ conflicts } 个{conflicts, plural, other {告警}}。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "使用 Elastic 代理来收集安全事件并防止您的终端受到威胁。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "添加安全集成", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "未找到页面", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "每页行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "正在显示", From 416580cfa44be0564136d8e1413f7959a1a4946a Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 23 Mar 2022 21:34:00 +0100 Subject: [PATCH 27/66] [Monitor management] Added inline errors (#124838) --- x-pack/plugins/observability/public/plugin.ts | 2 + .../get_app_data_view.ts | 25 +++ .../observability_data_views.ts | 3 + .../common/runtime_types/monitor/state.ts | 4 +- .../monitor_management/monitor_types.ts | 4 +- .../uptime/common/runtime_types/ping/ping.ts | 11 ++ .../common/runtime_types/ping/synthetics.ts | 3 + .../journeys/monitor_management.journey.ts | 2 +- .../e2e/page_objects/monitor_management.tsx | 2 +- x-pack/plugins/uptime/kibana.json | 1 + x-pack/plugins/uptime/public/apps/plugin.ts | 2 + .../common/header/action_menu_content.tsx | 2 +- .../action_bar/action_bar.tsx | 2 +- .../hooks/use_inline_errors.test.tsx | 83 ++++++++++ .../hooks/use_inline_errors.ts | 110 +++++++++++++ .../hooks/use_inline_errors_count.test.tsx | 79 ++++++++++ .../hooks/use_inline_errors_count.ts | 48 ++++++ .../hooks/use_invalid_monitors.tsx | 47 ++++++ .../monitor_list/actions.test.tsx | 2 +- .../monitor_list/actions.tsx | 25 ++- .../monitor_list/all_monitors.tsx | 34 ++++ .../monitor_list/delete_monitor.test.tsx | 4 +- .../monitor_list/inline_error.test.tsx | 62 ++++++++ .../monitor_list/inline_error.tsx | 51 ++++++ .../monitor_list/invalid_monitors.tsx | 53 +++++++ .../monitor_list/list_tabs.test.tsx | 35 +++++ .../monitor_list/list_tabs.tsx | 122 +++++++++++++++ .../monitor_list/monitor_list.test.tsx | 1 + .../monitor_list/monitor_list.tsx | 13 +- .../monitor_list/stderr_logs_popover.tsx | 55 +++++++ .../browser/browser_test_results.tsx | 4 +- .../test_now_mode/test_result_header.tsx | 12 +- .../columns/monitor_status_column.tsx | 27 ++-- .../columns/status_badge.test.tsx | 47 ++++++ .../monitor_list/columns/status_badge.tsx | 70 +++++++++ .../overview/monitor_list/monitor_list.tsx | 5 +- .../synthetics/check_steps/stderr_logs.tsx | 148 ++++++++++++++++++ .../check_steps/use_std_error_logs.ts | 65 ++++++++ .../contexts/uptime_refresh_context.tsx | 4 +- .../monitor_management/monitor_management.tsx | 58 +++++-- .../use_monitor_management_breadcrumbs.tsx | 3 +- x-pack/plugins/uptime/public/routes.tsx | 2 +- .../search/refine_potential_matches.ts | 2 + .../hydrate_saved_object.ts | 17 +- 44 files changed, 1302 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx create mode 100644 x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx create mode 100644 x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 3d2505ed80513..9d483b63ac0a9 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -47,6 +47,7 @@ import { updateGlobalNavigation } from './update_global_navigation'; import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; import { createUseRulesLink } from './hooks/create_use_rules_link'; +import getAppDataView from './utils/observability_data_views/get_app_data_view'; export type ObservabilityPublicSetup = ReturnType; @@ -280,6 +281,7 @@ export class Plugin PageTemplate, }, createExploratoryViewUrl, + getAppDataView: getAppDataView(pluginsStart.dataViews), ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart), useRulesLink: createUseRulesLink(config.unsafe.rules.enabled), }; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts new file mode 100644 index 0000000000000..4b4b03412c0c7 --- /dev/null +++ b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppDataType } from '../../components/shared/exploratory_view/types'; +import type { DataViewsPublicPluginStart } from '../../../../../../src/plugins/data_views/public'; + +const getAppDataView = (data: DataViewsPublicPluginStart) => { + return async (appId: AppDataType, indexPattern?: string) => { + try { + const { ObservabilityDataViews } = await import('./observability_data_views'); + + const obsvIndexP = new ObservabilityDataViews(data); + return await obsvIndexP.getDataView(appId, indexPattern); + } catch (e) { + return null; + } + }; +}; + +// eslint-disable-next-line import/no-default-export +export default getAppDataView; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts index 8a74482bb14ca..86ce6cd587213 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts @@ -176,3 +176,6 @@ export class ObservabilityDataViews { } } } + +// eslint-disable-next-line import/no-default-export +export default ObservabilityDataViews; diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts index d43fd5ad001f2..74a3bba6ae027 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { PingType } from '../ping/ping'; +import { PingErrorType, PingType } from '../ping/ping'; export const StateType = t.intersection([ t.type({ @@ -27,6 +27,7 @@ export const StateType = t.intersection([ monitor: t.intersection([ t.partial({ name: t.string, + checkGroup: t.string, duration: t.type({ us: t.number }), }), t.type({ @@ -47,6 +48,7 @@ export const StateType = t.intersection([ service: t.partial({ name: t.string, }), + error: PingErrorType, }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts index c63f5eb838d60..e0205b9362e23 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts @@ -222,7 +222,9 @@ export const SyntheticsMonitorWithIdCodec = t.intersection([ export type SyntheticsMonitorWithId = t.TypeOf; export const MonitorManagementListResultCodec = t.type({ - monitors: t.array(t.interface({ id: t.string, attributes: SyntheticsMonitorCodec })), + monitors: t.array( + t.interface({ id: t.string, attributes: SyntheticsMonitorCodec, updated_at: t.string }) + ), page: t.number, perPage: t.number, total: t.union([t.number, t.null]), diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index e78f026277d3a..6208e42868d9e 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -180,8 +180,14 @@ export const PingType = t.intersection([ }), }), observer: t.partial({ + hostname: t.string, + ip: t.array(t.string), + mac: t.array(t.string), geo: t.partial({ name: t.string, + continent_name: t.string, + city_name: t.string, + country_iso_code: t.string, location: t.union([ t.string, t.partial({ lat: t.number, lon: t.number }), @@ -221,6 +227,11 @@ export const PingType = t.intersection([ name: t.string, }), config_id: t.string, + data_stream: t.interface({ + namespace: t.string, + type: t.string, + dataset: t.string, + }), }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts index a143063d221b9..c95f9c281dc92 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -79,6 +79,9 @@ export const JourneyStepType = t.intersection([ }), }), synthetics: SyntheticsDataType, + error: t.type({ + message: t.string, + }), }), t.type({ _id: t.string, diff --git a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts index 1d6270c00df65..309cc5eb0ec6d 100644 --- a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts @@ -202,7 +202,7 @@ journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page; step('edit http monitor and check breadcrumb', async () => { await uptime.editMonitor(); // breadcrumb is available before edit page is loaded, make sure its edit view - await page.waitForSelector(byTestId('monitorManagementMonitorName')); + await page.waitForSelector(byTestId('monitorManagementMonitorName'), { timeout: 60 * 1000 }); const breadcrumbs = await page.$$('[data-test-subj=breadcrumb]'); expect(await breadcrumbs[1].textContent()).toEqual('Monitor management'); const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); diff --git a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx index a19f14fa1a6d1..b56cd8a361684 100644 --- a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx +++ b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx @@ -23,7 +23,7 @@ export function monitorManagementPageProvider({ const remotePassword = process.env.SYNTHETICS_REMOTE_KIBANA_PASSWORD; const isRemote = Boolean(process.env.SYNTHETICS_REMOTE_ENABLED); const basePath = isRemote ? remoteKibanaUrl : kibanaUrl; - const monitorManagement = `${basePath}/app/uptime/manage-monitors`; + const monitorManagement = `${basePath}/app/uptime/manage-monitors/all`; const addMonitor = `${basePath}/app/uptime/add-monitor`; const overview = `${basePath}/app/uptime`; return { diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 28a49067b6698..0ae53fe56b1a4 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -7,6 +7,7 @@ "alerting", "cases", "embeddable", + "discover", "encryptedSavedObjects", "features", "inspector", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index a5e2a85953d65..bf7c5336a8b0f 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -16,6 +16,7 @@ import { from } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { SharePluginSetup, SharePluginStart } from '../../../../../src/plugins/share/public'; +import { DiscoverStart } from '../../../../../src/plugins/discover/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { @@ -61,6 +62,7 @@ export interface ClientPluginsSetup { export interface ClientPluginsStart { fleet?: FleetStart; data: DataPublicPluginStart; + discover: DiscoverStart; inspector: InspectorPluginStart; embeddable: EmbeddableStart; observability: ObservabilityPublicStart; diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 985b1ae9146f2..0c059580b5461 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -85,7 +85,7 @@ export function ActionMenuContent({ config }: { config: UptimeConfig }): React.R color="text" data-test-subj="management-page-link" href={history.createHref({ - pathname: MONITOR_MANAGEMENT_ROUTE, + pathname: MONITOR_MANAGEMENT_ROUTE + '/all', })} > + ) : ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx new file mode 100644 index 0000000000000..369aa1461c425 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrors } from './use_inline_errors'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrors', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrors({ onlyInvalidMonitors: true }), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 3, + { + body: { + collapse: { field: 'config_id' }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 1000, + sort: [{ '@timestamp': 'desc' }], + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + true, + '@timestamp', + 'desc', + ], + { name: 'getInvalidMonitors' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts new file mode 100644 index 0000000000000..3753d95b8e858 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import moment from 'moment'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { Ping } from '../../../../common/runtime_types'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { useInlineErrorsCount } from './use_inline_errors_count'; + +const sortFieldMap: Record = { + name: 'monitor.name', + urls: 'url.full', + '@timestamp': '@timestamp', +}; + +export const getInlineErrorFilters = () => [ + { + exists: { + field: 'summary', + }, + }, + { + exists: { + field: 'error', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'error.message': 'journey did not finish executing', + }, + }, + { + match_phrase: { + 'error.message': 'ReferenceError:', + }, + }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + lte: moment().toISOString(), + gte: moment().subtract(5, 'minutes').toISOString(), + }, + }, + }, + EXCLUDE_RUN_ONCE_FILTER, +]; + +export function useInlineErrors({ + onlyInvalidMonitors, + sortField = '@timestamp', + sortOrder = 'desc', +}: { + onlyInvalidMonitors?: boolean; + sortField?: string; + sortOrder?: 'asc' | 'desc'; +}) { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const configIds = monitorList.list.monitors.map((monitor) => monitor.id); + + const doFetch = configIds.length > 0 || onlyInvalidMonitors; + + const { data, loading } = useEsSearch( + { + index: doFetch ? settings?.heartbeatIndices : '', + body: { + size: 1000, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + collapse: { field: 'config_id' }, + sort: [{ [sortFieldMap[sortField]]: sortOrder }], + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh, doFetch, sortField, sortOrder], + { name: 'getInvalidMonitors' } + ); + + const { count, loading: countLoading } = useInlineErrorsCount(); + + return useMemo(() => { + const errorSummaries = data?.hits.hits.map(({ _source: source }) => ({ + ...(source as Ping), + timestamp: (source as any)['@timestamp'], + })); + + return { loading: loading || countLoading, errorSummaries, count }; + }, [count, countLoading, data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx new file mode 100644 index 0000000000000..c4c864e7720cd --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrorsCount } from './use_inline_errors_count'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrorsCount', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrorsCount(), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 2, + { + body: { + aggs: { total: { cardinality: { field: 'config_id' } } }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 0, + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + ], + { name: 'getInvalidMonitorsCount' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts new file mode 100644 index 0000000000000..adda7c433b29c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { getInlineErrorFilters } from './use_inline_errors'; + +export function useInlineErrorsCount() { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const { data, loading } = useEsSearch( + { + index: settings?.heartbeatIndices, + body: { + size: 0, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + aggs: { + total: { + cardinality: { field: 'config_id' }, + }, + }, + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh], + { name: 'getInvalidMonitorsCount' } + ); + + return useMemo(() => { + const errorSummariesCount = data?.aggregations?.total.value; + + return { loading, count: errorSummariesCount }; + }, [data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx new file mode 100644 index 0000000000000..98e882e543a87 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useFetcher } from '../../../../../observability/public'; +import { Ping, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; + +export const useInvalidMonitors = (errorSummaries?: Ping[]) => { + const { savedObjects } = useKibana().services; + + const ids = (errorSummaries ?? []).map((summary) => ({ + id: summary.config_id!, + type: syntheticsMonitorType, + })); + + return useFetcher(async () => { + if (ids.length > 0) { + const response = await savedObjects?.client.bulkResolve(ids); + if (response) { + const { resolved_objects: resolvedObjects } = response; + return resolvedObjects + .filter((sv) => { + if (sv.saved_object.updatedAt) { + const errorSummary = errorSummaries?.find( + (summary) => summary.config_id === sv.saved_object.id + ); + if (errorSummary) { + return moment(sv.saved_object.updatedAt).isBefore(moment(errorSummary.timestamp)); + } + } + + return !Boolean(sv.saved_object.error); + }) + .map(({ saved_object: savedObject }) => ({ + ...savedObject, + updated_at: savedObject.updatedAt!, + })); + } + } + }, [errorSummaries]); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx index ec58ac7ee5010..f60d54e9cb4f6 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx @@ -15,7 +15,7 @@ describe('', () => { const onUpdate = jest.fn(); it('navigates to edit monitor flow on edit pencil', () => { - render(); + render(); expect(screen.getByLabelText('Edit monitor')).toHaveAttribute( 'href', diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx index 9d84263f3701e..47a0b8547ea8c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx @@ -8,19 +8,37 @@ import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import moment from 'moment'; import { UptimeSettingsContext } from '../../../contexts'; import { DeleteMonitor } from './delete_monitor'; +import { InlineError } from './inline_error'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; interface Props { id: string; name: string; isDisabled?: boolean; onUpdate: () => void; + errorSummaries?: Ping[]; + monitors: MonitorManagementListResult['monitors']; } -export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { +export const Actions = ({ id, name, onUpdate, isDisabled, errorSummaries, monitors }: Props) => { const { basePath } = useContext(UptimeSettingsContext); + let errorSummary = errorSummaries?.find((summary) => summary.config_id === id); + + const monitor = monitors.find((monitorT) => monitorT.id === id); + + if (errorSummary && monitor) { + const summaryIsBeforeUpdate = moment(monitor.updated_at).isBefore( + moment(errorSummary.timestamp) + ); + if (!summaryIsBeforeUpdate) { + errorSummary = undefined; + } + } + return ( @@ -35,6 +53,11 @@ export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { + {errorSummary && ( + + + + )} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx new file mode 100644 index 0000000000000..550d3b487a4ae --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { monitorManagementListSelector } from '../../../state/selectors'; +import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management'; +import { Ping } from '../../../../common/runtime_types'; + +interface Props { + pageState: MonitorManagementListPageState; + monitorList: MonitorManagementListState; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; +} +export const AllMonitors = ({ pageState, onPageStateChange, onUpdate, errorSummaries }: Props) => { + const monitorList = useSelector(monitorManagementListSelector); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx index 2e69196c86cff..f8a81a6efce0b 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx @@ -40,7 +40,7 @@ describe('', () => { it('calls set refresh when deletion is successful', () => { const id = 'test-id'; const name = 'sample monitor'; - render(); + render(); userEvent.click(screen.getByLabelText('Delete monitor')); @@ -54,7 +54,7 @@ describe('', () => { status: FETCH_STATUS.LOADING, refetch: () => {}, }); - render(); + render(); expect(screen.getByLabelText('Deleting monitor...')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx new file mode 100644 index 0000000000000..1cf05d7697e60 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { InlineError } from './inline_error'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + render(); + + expect( + screen.getByLabelText( + 'journey did not finish executing, 0 steps ran. Click for more details.' + ) + ).toBeInTheDocument(); + }); +}); + +const errorSummary = { + docId: 'testDoc', + summary: { up: 0, down: 1 }, + agent: { + name: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + id: '778fe9c6-bbd1-47d4-a0be-73f8ba9cec61', + type: 'heartbeat', + ephemeral_id: 'bc1a961f-1fbc-4253-aee0-633a8f6fc56e', + version: '8.1.0', + }, + synthetics: { type: 'heartbeat/summary' }, + monitor: { + name: 'Browser monitor', + check_group: 'f5480358-a9da-11ec-bced-6274e5883bd7', + id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + timespan: { lt: '2022-03-22T12:27:02.563Z', gte: '2022-03-22T12:24:02.563Z' }, + type: 'browser', + status: 'down', + }, + error: { type: 'io', message: 'journey did not finish executing, 0 steps ran' }, + url: {}, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + ip: ['10.1.9.110'], + mac: ['62:74:e5:88:3b:d7'], + }, + ecs: { version: '8.0.0' }, + config_id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + timestamp: '2022-03-22T12:24:02.563Z', +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx new file mode 100644 index 0000000000000..187c81ff8c6e9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Ping } from '../../../../common/runtime_types'; +import { StdErrorPopover } from './stderr_logs_popover'; + +export const InlineError = ({ errorSummary }: { errorSummary: Ping }) => { + const [isOpen, setIsOpen] = useState(false); + + const errorMessage = + errorSummary.monitor.type === 'browser' + ? getInlineErrorLabel(errorSummary.error?.message) + : errorSummary.error?.message; + + return ( + + setIsOpen(true)} + color="danger" + /> + + } + /> + ); +}; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.message', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx new file mode 100644 index 0000000000000..4b524a2b52312 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; + +interface Props { + loading: boolean; + pageState: MonitorManagementListPageState; + monitorSavedObjects?: MonitorManagementListResult['monitors']; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; + invalidTotal: number; +} +export const InvalidMonitors = ({ + loading: summariesLoading, + pageState, + onPageStateChange, + onUpdate, + errorSummaries, + invalidTotal, + monitorSavedObjects, +}: Props) => { + const { pageSize, pageIndex } = pageState; + + const startIndex = (pageIndex - 1) * pageSize; + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx new file mode 100644 index 0000000000000..bfac60de96bc7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { MonitorListTabs } from './list_tabs'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + const onPageStateChange = jest.fn(); + render( + + ); + + expect(screen.getByText('All monitors')).toBeInTheDocument(); + expect(screen.getByText('Invalid monitors')).toBeInTheDocument(); + + expect(onPageStateChange).toHaveBeenCalledTimes(1); + expect(onPageStateChange).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: 10, + sortField: 'name', + sortOrder: 'asc', + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx new file mode 100644 index 0000000000000..1aad6d4d888e5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiTabs, + EuiTab, + EuiNotificationBadge, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState, Fragment, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { MonitorManagementListPageState } from './monitor_list'; +import { ConfigKey } from '../../../../common/runtime_types'; + +export const MonitorListTabs = ({ + invalidTotal, + onUpdate, + onPageStateChange, +}: { + invalidTotal: number; + onUpdate: () => void; + onPageStateChange: (state: MonitorManagementListPageState) => void; +}) => { + const [selectedTabId, setSelectedTabId] = useState('all'); + + const { refreshApp } = useUptimeRefreshContext(); + + const history = useHistory(); + + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + + useEffect(() => { + setSelectedTabId(viewType); + onPageStateChange({ pageIndex: 1, pageSize: 10, sortOrder: 'asc', sortField: ConfigKey.NAME }); + }, [viewType, onPageStateChange]); + + const tabs = [ + { + id: 'all', + name: ALL_MONITORS_LABEL, + content: , + href: history.createHref({ pathname: '/manage-monitors/all' }), + disabled: false, + }, + { + id: 'invalid', + name: INVALID_MONITORS_LABEL, + append: ( + + {invalidTotal} + + ), + href: history.createHref({ pathname: '/manage-monitors/invalid' }), + content: , + disabled: invalidTotal === 0, + }, + ]; + + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + append={tab.append} + > + {tab.name} + + )); + }; + + return ( + + + {renderTabs()} + + + { + onUpdate(); + refreshApp(); + }} + > + {REFRESH_LABEL} + + + + ); +}; + +export const REFRESH_LABEL = i18n.translate('xpack.uptime.monitorList.refresh', { + defaultMessage: 'Refresh', +}); + +export const INVALID_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.invalidMonitors', { + defaultMessage: 'Invalid monitors', +}); + +export const ALL_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.allMonitors', { + defaultMessage: 'All monitors', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx index d9fb207f4fa20..ff5d9ebf13ccf 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx @@ -20,6 +20,7 @@ describe('', () => { for (let i = 0; i < 12; i++) { monitors.push({ id: `test-monitor-id-${i}`, + updated_at: '123', attributes: { name: `test-monitor-${i}`, enabled: true, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx index 5d18fdcaca6fe..8bae4160f6b0c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx @@ -21,6 +21,7 @@ import { FetchMonitorManagementListQueryArgs, ICMPSimpleFields, MonitorFields, + Ping, ServiceLocations, SyntheticsMonitorWithId, TCPSimpleFields, @@ -47,6 +48,7 @@ interface Props { monitorList: MonitorManagementListState; onPageStateChange: (state: MonitorManagementListPageState) => void; onUpdate: () => void; + errorSummaries?: Ping[]; } export const MonitorManagementList = ({ @@ -58,13 +60,18 @@ export const MonitorManagementList = ({ }, onPageStateChange, onUpdate, + errorSummaries, }: Props) => { const { basePath } = useContext(UptimeSettingsContext); const isXl = useBreakpoints().up('xl'); const { total } = list as MonitorManagementListState['list']; const monitors: SyntheticsMonitorWithId[] = useMemo( - () => list.monitors.map((monitor) => ({ ...monitor.attributes, id: monitor.id })), + () => + list.monitors.map((monitor) => ({ + ...monitor.attributes, + id: monitor.id, + })), [list.monitors] ); @@ -90,7 +97,7 @@ export const MonitorManagementList = ({ pageIndex: pageIndex - 1, // page index for EuiBasicTable is base 0 pageSize, totalItemCount: total || 0, - pageSizeOptions: [10, 25, 50, 100], + pageSizeOptions: [5, 10, 25, 50, 100], }; const sorting: EuiTableSortingType = { @@ -188,6 +195,8 @@ export const MonitorManagementList = ({ name={fields[ConfigKey.NAME]} isDisabled={!canEdit} onUpdate={onUpdate} + errorSummaries={errorSummaries} + monitors={list.monitors} /> ), }, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx new file mode 100644 index 0000000000000..c50cd33b13b1f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { StdErrorLogs } from '../../synthetics/check_steps/stderr_logs'; + +export const StdErrorPopover = ({ + checkGroup, + button, + isOpen, + setIsOpen, + summaryMessage, +}: { + isOpen: boolean; + setIsOpen: (val: boolean) => void; + checkGroup: string; + summaryMessage?: string; + button: JSX.Element; +}) => { + return ( + setIsOpen(false)} button={button}> + + + + + ); +}; + +const Container = styled.div` + width: 650px; + height: 400px; + overflow: scroll; +`; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.messageLabel', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx index c6074626bad1e..3e798dd3fbe62 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx @@ -75,7 +75,9 @@ export const BrowserTestRunResult = ({ monitorId, isMonitorSaved, onDone }: Prop initialIsOpen={true} > {isStepsLoading && {LOADING_STEPS}} - {isStepsLoadingFailed && {FAILED_TO_RUN}} + {isStepsLoadingFailed && ( + {summaryDoc?.error?.message ?? FAILED_TO_RUN} + )} {stepEnds.length > 0 && stepListData?.steps && ( 0) { summaryDocs.forEach((sDoc) => { - duration += sDoc.monitor.duration!.us; + duration += sDoc.monitor.duration?.us ?? 0; }); } + const summaryDoc = summaryDocs?.[0] as Ping; + return ( @@ -48,7 +50,9 @@ export function TestResultHeader({ doc, title, summaryDocs, journeyStarted, isCo {isCompleted ? ( - {COMPLETED_LABEL} + 0 ? 'danger' : 'success'}> + {summaryDoc?.summary?.down! > 0 ? FAILED_LABEL : COMPLETED_LABEL} + @@ -98,6 +102,10 @@ const COMPLETED_LABEL = i18n.translate('xpack.uptime.monitorManagement.completed defaultMessage: 'COMPLETED', }); +const FAILED_LABEL = i18n.translate('xpack.uptime.monitorManagement.failed', { + defaultMessage: 'FAILED', +}); + export const IN_PROGRESS_LABEL = i18n.translate('xpack.uptime.monitorManagement.inProgress', { defaultMessage: 'IN PROGRESS', }); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx index 60baedaa7830c..896ab6bc662bb 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback } from 'react'; import moment, { Moment } from 'moment'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -14,14 +14,13 @@ import { EuiFlexItem, EuiText, EuiToolTip, - EuiBadge, EuiSpacer, EuiHighlight, EuiHorizontalRule, } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; import { parseTimestamp } from '../parse_timestamp'; -import { DataStream, Ping } from '../../../../../common/runtime_types'; +import { DataStream, Ping, PingError } from '../../../../../common/runtime_types'; import { STATUS, SHORT_TIMESPAN_LOCALE, @@ -29,22 +28,24 @@ import { SHORT_TS_LOCALE, } from '../../../../../common/constants'; -import { UptimeThemeContext } from '../../../../contexts'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../../common/translations'; import { MonitorProgress } from './progress/monitor_progress'; import { refreshedMonitorSelector } from '../../../../state/reducers/monitor_list'; import { testNowRunSelector } from '../../../../state/reducers/test_now_runs'; import { clearTestNowMonitorAction } from '../../../../state/actions'; +import { StatusBadge } from './status_badge'; interface MonitorListStatusColumnProps { configId?: string; monitorId?: string; + checkGroup?: string; status: string; monitorType: string; timestamp: string; duration?: number; summaryPings: Ping[]; + summaryError?: PingError; } const StatusColumnFlexG = styled(EuiFlexGroup)` @@ -167,15 +168,13 @@ export const MonitorListStatusColumn = ({ monitorId, status, duration, + checkGroup, + summaryError, summaryPings = [], timestamp: tsString, }: MonitorListStatusColumnProps) => { const timestamp = parseTimestamp(tsString); - const { - colors: { dangerBehindText }, - } = useContext(UptimeThemeContext); - const { statusMessage, locTooltip } = getLocationStatus(summaryPings, status); const dispatch = useDispatch(); @@ -204,12 +203,12 @@ export const MonitorListStatusColumn = ({ stopProgressTrack={stopProgressTrack} /> ) : ( - - {getHealthMessage(status)} - + )} diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx new file mode 100644 index 0000000000000..992defffc5552 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { StatusBadge } from './status_badge'; +import { render } from '../../../../lib/helper/rtl_helpers'; + +describe('', () => { + it('render no error for up status', () => { + render(); + + expect(screen.getByText('Up')).toBeInTheDocument(); + }); + + it('renders errors for downs state', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect( + screen.getByLabelText('journey did not run. Click for more details.') + ).toBeInTheDocument(); + }); + + it('renders errors for downs state for http monitor', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect(screen.getByLabelText('journey did not run')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx new file mode 100644 index 0000000000000..fe2c7730275db --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import React, { useContext, useState } from 'react'; +import { STATUS } from '../../../../../common/constants'; +import { getHealthMessage } from './monitor_status_column'; +import { UptimeThemeContext } from '../../../../contexts'; +import { PingError } from '../../../../../common/runtime_types'; +import { getInlineErrorLabel } from '../../../monitor_management/monitor_list/inline_error'; +import { StdErrorPopover } from '../../../monitor_management/monitor_list/stderr_logs_popover'; + +export const StatusBadge = ({ + status, + checkGroup, + summaryError, + monitorType, +}: { + status: string; + monitorType: string; + checkGroup?: string; + summaryError?: PingError; +}) => { + const { + colors: { dangerBehindText }, + } = useContext(UptimeThemeContext); + const [isOpen, setIsOpen] = useState(false); + + if (status === STATUS.UP) { + return ( + + {getHealthMessage(status)} + + ); + } + + const errorMessage = + monitorType !== 'browser' ? summaryError?.message : getInlineErrorLabel(summaryError?.message); + + const button = ( + + setIsOpen(true)} + onClickAriaLabel={errorMessage} + > + {getHealthMessage(status)} + + + ); + + if (monitorType !== 'browser') { + return button; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index a2d823cd90af1..552256a6aff1a 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -123,7 +123,8 @@ export const MonitorListComponent: ({ state: { timestamp, summaryPings, - monitor: { type, duration }, + monitor: { type, duration, checkGroup }, + error: summaryError, }, configId, }: MonitorSummary @@ -137,6 +138,8 @@ export const MonitorListComponent: ({ monitorType={type} duration={duration?.us} monitorId={monitorId} + checkGroup={checkGroup} + summaryError={summaryError} /> ); }, diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx new file mode 100644 index 0000000000000..cef4ff550a23d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTableColumn, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHighlight, + EuiLink, + EuiSpacer, + EuiTitle, + formatDate, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiInMemoryTable } from '@elastic/eui'; +import moment from 'moment'; +import { useSelector } from 'react-redux'; +import { useStdErrorLogs } from './use_std_error_logs'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ClientPluginsStart } from '../../../apps/plugin'; +import { useFetcher } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const StdErrorLogs = ({ + configId, + checkGroup, + timestamp, + title, + summaryMessage, +}: { + configId?: string; + checkGroup?: string; + timestamp?: string; + title?: string; + summaryMessage?: string; +}) => { + const columns = [ + { + field: '@timestamp', + name: TIMESTAMP_LABEL, + sortable: true, + render: (date: string) => formatDate(date, 'dateTime'), + }, + { + field: 'synthetics.payload.message', + name: 'Message', + render: (message: string) => ( + + {message} + + ), + }, + ] as Array>; + + const { items, loading } = useStdErrorLogs({ configId, checkGroup }); + + const { discover, observability } = useKibana().services; + + const { settings } = useSelector(selectDynamicSettings); + + const { data: discoverLink } = useFetcher(async () => { + if (settings?.heartbeatIndices) { + const dataView = await observability.getAppDataView('synthetics', settings?.heartbeatIndices); + return discover.locator?.getUrl({ + query: { language: 'kuery', query: `monitor.check_group: ${checkGroup}` }, + indexPatternId: dataView?.id, + columns: ['synthetics.payload.message', 'error.message'], + timeRange: timestamp + ? { + from: moment(timestamp).subtract(10, 'minutes').toISOString(), + to: moment(timestamp).add(5, 'minutes').toISOString(), + } + : undefined, + }); + } + return ''; + }, [checkGroup, timestamp]); + + const search = { + box: { + incremental: true, + }, + }; + + return ( + <> + + + +

    {title ?? TEST_RUN_LOGS_LABEL}

    +
    +
    + + + + {VIEW_IN_DISCOVER_LABEL} + + + +
    + + +

    {summaryMessage}

    +
    + + + + + + ); +}; + +export const TIMESTAMP_LABEL = i18n.translate('xpack.uptime.monitorList.timestamp', { + defaultMessage: 'Timestamp', +}); + +export const ERROR_SUMMARY_LABEL = i18n.translate('xpack.uptime.monitorList.errorSummary', { + defaultMessage: 'Error summary', +}); + +export const VIEW_IN_DISCOVER_LABEL = i18n.translate('xpack.uptime.monitorList.viewInDiscover', { + defaultMessage: 'View in discover', +}); + +export const TEST_RUN_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.testRunLogs', { + defaultMessage: 'Test run logs', +}); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts new file mode 100644 index 0000000000000..fa563b2ef2728 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSelector } from 'react-redux'; +import { createEsParams, useEsSearch } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const useStdErrorLogs = ({ + configId, + checkGroup, +}: { + configId?: string; + checkGroup?: string; +}) => { + const { settings } = useSelector(selectDynamicSettings); + const { data, loading } = useEsSearch( + createEsParams({ + index: !configId && !checkGroup ? '' : settings?.heartbeatIndices, + body: { + size: 1000, + query: { + bool: { + filter: [ + { + term: { + 'synthetics.type': 'stderr', + }, + }, + ...(configId + ? [ + { + term: { + config_id: configId, + }, + }, + ] + : []), + ...(checkGroup + ? [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + ] + : []), + ], + }, + }, + }, + }), + [settings?.heartbeatIndices], + { name: 'getStdErrLogs' } + ); + + return { + items: data?.hits.hits.map((hit) => ({ ...(hit._source as Ping), id: hit._id })) ?? [], + loading, + }; +}; diff --git a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx index 7f81628129d3e..12d904ae3c4b5 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { createContext, useMemo, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; interface UptimeRefreshContext { lastRefresh: number; @@ -35,3 +35,5 @@ export const UptimeRefreshContextProvider: React.FC = ({ children }) => { return ; }; + +export const useUptimeRefreshContext = () => useContext(UptimeRefreshContext); diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index 0ad9dbd6b06e7..d826db82517fc 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -7,15 +7,18 @@ import React, { useEffect, useReducer, useCallback, Reducer } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { useTrackPageview } from '../../../../observability/public'; import { ConfigKey } from '../../../common/runtime_types'; import { getMonitors } from '../../state/actions'; import { monitorManagementListSelector } from '../../state/selectors'; -import { - MonitorManagementList, - MonitorManagementListPageState, -} from '../../components/monitor_management/monitor_list/monitor_list'; +import { MonitorManagementListPageState } from '../../components/monitor_management/monitor_list/monitor_list'; import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs'; +import { useInlineErrors } from '../../components/monitor_management/hooks/use_inline_errors'; +import { MonitorListTabs } from '../../components/monitor_management/monitor_list/list_tabs'; +import { AllMonitors } from '../../components/monitor_management/monitor_list/all_monitors'; +import { InvalidMonitors } from '../../components/monitor_management/monitor_list/invalid_monitors'; +import { useInvalidMonitors } from '../../components/monitor_management/hooks/use_invalid_monitors'; export const MonitorManagementPage: React.FC = () => { const [pageState, dispatchPageAction] = useReducer( @@ -47,17 +50,48 @@ export const MonitorManagementPage: React.FC = () => { const { pageIndex, pageSize, sortField, sortOrder } = pageState as MonitorManagementListPageState; + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + const { errorSummaries, loading, count } = useInlineErrors({ + onlyInvalidMonitors: viewType === 'invalid', + sortField: pageState.sortField, + sortOrder: pageState.sortOrder, + }); + useEffect(() => { - dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); - }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder]); + if (viewType === 'all') { + dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); + } + }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder, viewType]); + + const { data: monitorSavedObjects, loading: objectsLoading } = useInvalidMonitors(errorSummaries); return ( - + <> + + {viewType === 'all' ? ( + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx index e5784591a00fc..834752c996153 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx @@ -25,7 +25,8 @@ export const useMonitorManagementBreadcrumbs = ({ useBreadcrumbs([ { text: MONITOR_MANAGEMENT_CRUMB, - href: isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}` : undefined, + href: + isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}/all` : undefined, }, ...(isAddMonitor ? [ diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 5d7e0a46a29d3..e68f25fcbb134 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -237,7 +237,7 @@ const getRoutes = (config: UptimeConfig, canSave: boolean): RouteProps[] => { defaultMessage: 'Manage Monitors | {baseTitle}', values: { baseTitle }, }), - path: MONITOR_MANAGEMENT_ROUTE, + path: MONITOR_MANAGEMENT_ROUTE + '/:type', component: MonitorManagementPage, dataTestSubj: 'uptimeMonitorManagementListPage', telemetryId: UptimePage.MonitorManagement, diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index 5a714fd2514d8..6359a122638f2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -90,6 +90,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { name: latest.monitor?.name, type: latest.monitor?.type, duration: latest.monitor?.duration, + checkGroup: latest.monitor?.check_group, }, url: latest.url ?? {}, summary: { @@ -104,6 +105,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { geo: { name: summaryPings.map((p) => p.observer?.geo?.name ?? '').filter((n) => n !== '') }, }, service: summaryPings.find((p) => p.service?.name)?.service, + error: latest.error, }, }; }; diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 3d132e74d24d5..f240652b27691 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -43,11 +43,14 @@ export const hydrateSavedObjects = async ({ missingInfoIds ); - const updatedObjects = monitors + const updatedObjects: SyntheticsMonitorSavedObject[] = []; + monitors .filter((monitor) => missingInfoIds.includes(monitor.id)) - .map((monitor) => { + .forEach((monitor) => { let resultAttributes: Partial = monitor.attributes; + let isUpdated = false; + esDocs.forEach((doc) => { // to make sure the document is ingested after the latest update of the monitor const documentIsAfterLatestUpdate = moment(monitor.updated_at).isBefore( @@ -57,15 +60,21 @@ export const hydrateSavedObjects = async ({ if (doc.config_id !== monitor.id) return monitor; if (doc.url?.full) { + isUpdated = true; resultAttributes = { ...resultAttributes, urls: doc.url?.full }; } if (doc.url?.port) { + isUpdated = true; resultAttributes = { ...resultAttributes, ['url.port']: doc.url?.port }; } }); - - return { ...monitor, attributes: resultAttributes }; + if (isUpdated) { + updatedObjects.push({ + ...monitor, + attributes: resultAttributes, + } as SyntheticsMonitorSavedObject); + } }); await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); From 70e0133691cf30dc2ba5d2815f3a48d08db8fc03 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 23 Mar 2022 16:37:48 -0400 Subject: [PATCH 28/66] [Response Ops] Change search strategy to private (#127792) * Privatize * Add test * Fix types * debug for ci * try fetching version * Use this Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/get_is_kibana_request.test.ts | 34 +++++++++++ .../server/lib/get_is_kibana_request.ts | 17 ++++++ x-pack/plugins/rule_registry/server/plugin.ts | 4 +- .../server/search_strategy/index.ts | 2 +- .../search_strategy/search_strategy.test.ts | 55 ++++++++++++++++- .../server/search_strategy/search_strategy.ts | 15 ++++- x-pack/test/common/services/bsearch_secure.ts | 40 ++++++++++-- .../tests/basic/search_strategy.ts | 61 +++++++++++++++---- 8 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts create mode 100644 x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts new file mode 100644 index 0000000000000..7dc0f51f15f08 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getIsKibanaRequest } from './get_is_kibana_request'; + +describe('getIsKibanaRequest', () => { + it('should ensure the request has a kbn version and referer', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + referer: 'somwhere', + }) + ).toBe(true); + }); + + it('should return false if the kbn version is missing', () => { + expect( + getIsKibanaRequest({ + referer: 'somwhere', + }) + ).toBe(false); + }); + + it('should return false if the referer is missing', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + }) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts new file mode 100644 index 0000000000000..c0961b84c7c28 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Headers } from 'kibana/server'; + +/** + * Taken from + * https://github.com/elastic/kibana/blob/ec30f2aeeb10fb64b507935e558832d3ef5abfaa/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts#L113-L118 + */ +export const getIsKibanaRequest = (headers?: Headers): boolean => { + // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return !!(headers && headers['kbn-version'] && headers.referer); +}; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 292e987879d58..df32abcc80865 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -29,7 +29,7 @@ import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; import { AlertsClient } from './alert_data_client/alerts_client'; import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types'; import { defineRoutes } from './routes'; -import { ruleRegistrySearchStrategyProvider } from './search_strategy'; +import { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; export interface RuleRegistryPluginSetupDependencies { security?: SecurityPluginSetup; @@ -115,7 +115,7 @@ export class RuleRegistryPlugin ); plugins.data.search.registerSearchStrategy( - 'ruleRegistryAlertsSearchStrategy', + RULE_SEARCH_STRATEGY_NAME, ruleRegistrySearchStrategy ); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/index.ts b/x-pack/plugins/rule_registry/server/search_strategy/index.ts index 63f39430a5522..d6364983f2d26 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ruleRegistrySearchStrategyProvider } from './search_strategy'; +export { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts index 2ea4b4c191c0d..f5f7d8d164b48 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts @@ -8,7 +8,11 @@ import { of } from 'rxjs'; import { merge } from 'lodash'; import { loggerMock } from '@kbn/logging-mocks'; import { AlertConsumers } from '@kbn/rule-data-utils'; -import { ruleRegistrySearchStrategyProvider, EMPTY_RESPONSE } from './search_strategy'; +import { + ruleRegistrySearchStrategyProvider, + EMPTY_RESPONSE, + RULE_SEARCH_STRATEGY_NAME, +} from './search_strategy'; import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock'; import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server'; @@ -18,6 +22,9 @@ import { spacesMock } from '../../../spaces/server/mocks'; import { RuleRegistrySearchRequest } from '../../common/search_strategy'; import { IndexInfo } from '../rule_data_plugin_service/index_info'; import * as getAuthzFilterImport from '../lib/get_authz_filter'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; + +jest.mock('../lib/get_is_kibana_request'); const getBasicResponse = (overwrites = {}) => { return merge( @@ -89,6 +96,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => { return of(response); }); + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return true; + }); + getAuthzFilterSpy = jest .spyOn(getAuthzFilterImport, 'getAuthzFilter') .mockImplementation(async () => { @@ -377,4 +388,46 @@ describe('ruleRegistrySearchStrategyProvider()', () => { (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.sort ).toStrictEqual([{ test: { order: 'desc' } }]); }); + + it('should reject, to the best of our ability, public requests', async () => { + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return false; + }); + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + sort: [ + { + test: { + order: 'desc', + }, + }, + ], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + let err = null; + try { + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + } catch (e) { + err = e; + } + expect(err).not.toBeNull(); + expect(err.message).toBe( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index 8cd0a0d410c9b..da32d68a85f86 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -5,6 +5,7 @@ * 2.0. */ import { map, mergeMap, catchError } from 'rxjs/operators'; +import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger } from 'src/core/server'; import { from, of } from 'rxjs'; @@ -23,11 +24,14 @@ import { Dataset } from '../rule_data_plugin_service/index_options'; import { MAX_ALERT_SEARCH_SIZE } from '../../common/constants'; import { AlertAuditAction, alertAuditEvent } from '../'; import { getSpacesFilter, getAuthzFilter } from '../lib'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; export const EMPTY_RESPONSE: RuleRegistrySearchResponse = { rawResponse: {} as RuleRegistrySearchResponse['rawResponse'], }; +export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy'; + export const ruleRegistrySearchStrategyProvider = ( data: PluginStart, ruleDataService: IRuleDataService, @@ -40,6 +44,13 @@ export const ruleRegistrySearchStrategyProvider = ( const requestUserEs = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); return { search: (request, options, deps) => { + // We want to ensure this request came from our UI. We can't really do this + // but we have a best effort we can try + if (!getIsKibanaRequest(deps.request.headers)) { + throw Boom.notFound( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + } // SIEM uses RBAC fields in their alerts but also utilizes ES DLS which // is different than every other solution so we need to special case // those requests. @@ -48,7 +59,7 @@ export const ruleRegistrySearchStrategyProvider = ( siemRequest = true; } else if (request.featureIds.includes(AlertConsumers.SIEM)) { throw new Error( - 'The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.' + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); } @@ -74,7 +85,7 @@ export const ruleRegistrySearchStrategyProvider = ( const indices: string[] = request.featureIds.reduce((accum: string[], featureId) => { if (!isValidFeatureId(featureId)) { logger.warn( - `Found invalid feature '${featureId}' while using rule registry search strategy. No alert data from this feature will be searched.` + `Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.` ); return accum; } diff --git a/x-pack/test/common/services/bsearch_secure.ts b/x-pack/test/common/services/bsearch_secure.ts index 622cca92aead5..c1aa173280f54 100644 --- a/x-pack/test/common/services/bsearch_secure.ts +++ b/x-pack/test/common/services/bsearch_secure.ts @@ -29,6 +29,8 @@ const getSpaceUrlPrefix = (spaceId?: string): string => { interface SendOptions { supertestWithoutAuth: SuperTest.SuperTest; auth: { username: string; password: string }; + referer?: string; + kibanaVersion?: string; options: object; strategy: string; space?: string; @@ -38,17 +40,45 @@ export const BSecureSearchFactory = (retry: RetryService) => ({ send: async ({ supertestWithoutAuth, auth, + referer, + kibanaVersion, options, strategy, space, }: SendOptions): Promise => { const spaceUrl = getSpaceUrlPrefix(space); const { body } = await retry.try(async () => { - const result = await supertestWithoutAuth - .post(`${spaceUrl}/internal/search/${strategy}`) - .auth(auth.username, auth.password) - .set('kbn-xsrf', 'true') - .send(options); + let result; + const url = `${spaceUrl}/internal/search/${strategy}`; + if (referer && kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else if (referer) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-xsrf', 'true') + .send(options); + } else if (kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-xsrf', 'true') + .send(options); + } if (result.status === 500 || result.status === 200) { return result; } diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts index 745995588d8b3..2c203a4ffbcd3 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts @@ -26,18 +26,28 @@ import { logsOnlySpacesAll, } from '../../../common/lib/authentication/users'; +type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { + statusCode: number; + message: string; +}; + // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - // const bsearch = getService('bsearch'); const secureBsearch = getService('secureBsearch'); const log = getService('log'); + const kbnClient = getService('kibanaServer'); const SPACE1 = 'space1'; describe('ruleRegistryAlertsSearchStrategy', () => { + let kibanaVersion: string; + before(async () => { + kibanaVersion = await kbnClient.version.get(); + }); + describe('logs', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); @@ -53,10 +63,12 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); const consumers = result.rawResponse.hits.hits.map((hit) => { @@ -72,6 +84,8 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], pagination: { @@ -86,7 +100,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); expect(result.rawResponse.hits.hits.length).to.eql(2); @@ -94,9 +108,26 @@ export default ({ getService }: FtrProviderContext) => { const second = result.rawResponse.hits.hits[1].fields?.['kibana.alert.evaluation.value']; expect(first > second).to.be(true); }); + + it('should reject public requests', async () => { + const result = await secureBsearch.send({ + supertestWithoutAuth, + auth: { + username: logsOnlySpacesAll.username, + password: logsOnlySpacesAll.password, + }, + options: { + featureIds: [AlertConsumers.LOGS], + }, + strategy: 'privateRuleRegistryAlertsSearchStrategy', + }); + expect(result.statusCode).to.be(500); + expect(result.message).to.be( + `The privateRuleRegistryAlertsSearchStrategy search strategy is currently only available for internal use.` + ); + }); }); - // TODO: need xavier's help here describe('siem', () => { before(async () => { await createSignalsIndex(supertest, log); @@ -126,10 +157,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(1); const consumers = result.rawResponse.hits.hits.map( @@ -139,24 +172,22 @@ export default ({ getService }: FtrProviderContext) => { }); it('should throw an error when trying to to search for more than just siem', async () => { - type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { - statusCode: number; - message: string; - }; const result = await secureBsearch.send({ supertestWithoutAuth, auth: { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.statusCode).to.be(500); expect(result.message).to.be( - `The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` + `The privateRuleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); }); }); @@ -176,10 +207,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.APM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', space: SPACE1, }); expect(result.rawResponse.hits.total).to.eql(2); @@ -198,10 +231,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse).to.eql({}); }); From 5b4642b3658af666fcba6d5176b1dfe4202b84c0 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 23 Mar 2022 16:06:40 -0500 Subject: [PATCH 29/66] [build] Cross compile docker images (#128272) * [build] Cross compile docker images * typo * debug * Revert "[build] Cross compile docker images" This reverts commit 621780eb1d85076893e8a45b000b9886126c3153. * revert * support docker-cross-compile flag * fix types/tests * fix more tests * download cloud dependencies based on cross compile flag * fix array * fix more tests --- src/dev/build/args.test.ts | 7 +++++ src/dev/build/args.ts | 3 ++ src/dev/build/build_distributables.ts | 1 + src/dev/build/cli.ts | 1 + src/dev/build/lib/build.test.ts | 1 + src/dev/build/lib/config.test.ts | 1 + src/dev/build/lib/config.ts | 11 ++++++++ src/dev/build/lib/runner.test.ts | 1 + .../tasks/download_cloud_dependencies.ts | 28 +++++++++++-------- .../nodejs/download_node_builds_task.test.ts | 1 + .../nodejs/extract_node_builds_task.test.ts | 1 + .../verify_existing_node_builds_task.test.ts | 1 + .../tasks/os_packages/docker_generator/run.ts | 3 +- .../templates/build_docker_sh.template.ts | 3 +- 14 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index c06c13230c63f..b0f39840ba440 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -36,6 +36,7 @@ it('build default and oss dist for current platform, without packages, by defaul "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -67,6 +68,7 @@ it('builds packages if --all-platforms is passed', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -98,6 +100,7 @@ it('limits packages if --rpm passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -129,6 +132,7 @@ it('limits packages if --deb passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -161,6 +165,7 @@ it('limits packages if --docker passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -200,6 +205,7 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -232,6 +238,7 @@ it('limits packages if --all-platforms passed with --skip-docker-ubuntu', () => "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 03fe49b72c954..2bad2c0721e2e 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -22,6 +22,7 @@ export function readCliArgs(argv: string[]) { 'skip-os-packages', 'rpm', 'deb', + 'docker-cross-compile', 'docker-images', 'docker-push', 'skip-docker-contexts', @@ -52,6 +53,7 @@ export function readCliArgs(argv: string[]) { rpm: null, deb: null, 'docker-images': null, + 'docker-cross-compile': false, 'docker-push': false, 'docker-tag-qualifier': null, 'version-qualifier': '', @@ -112,6 +114,7 @@ export function readCliArgs(argv: string[]) { const buildOptions: BuildOptions = { isRelease: Boolean(flags.release), versionQualifier: flags['version-qualifier'], + dockerCrossCompile: Boolean(flags['docker-cross-compile']), dockerPush: Boolean(flags['docker-push']), dockerTagQualifier: flags['docker-tag-qualifier'], initialize: !Boolean(flags['skip-initialize']), diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 4fb849988cb60..d2b2d24667bce 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -13,6 +13,7 @@ import * as Tasks from './tasks'; export interface BuildOptions { isRelease: boolean; + dockerCrossCompile: boolean; dockerPush: boolean; dockerTagQualifier: string | null; downloadFreshNode: boolean; diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index ffcbb68215ab7..561e2aea5c15d 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -39,6 +39,7 @@ if (showHelp) { --rpm {dim Only build the rpm packages} --deb {dim Only build the deb packages} --docker-images {dim Only build the Docker images} + --docker-cross-compile {dim Produce arm64 and amd64 Docker images} --docker-contexts {dim Only build the Docker build contexts} --skip-docker-ubi {dim Don't build the docker ubi image} --skip-docker-ubuntu {dim Don't build the docker ubuntu image} diff --git a/src/dev/build/lib/build.test.ts b/src/dev/build/lib/build.test.ts index 8ea2a20d83960..3da87ff13b1ee 100644 --- a/src/dev/build/lib/build.test.ts +++ b/src/dev/build/lib/build.test.ts @@ -32,6 +32,7 @@ const config = new Config( buildSha: 'abcd1234', buildVersion: '8.0.0', }, + false, '', false, true diff --git a/src/dev/build/lib/config.test.ts b/src/dev/build/lib/config.test.ts index 3f90c8738d8e2..2195406270bdd 100644 --- a/src/dev/build/lib/config.test.ts +++ b/src/dev/build/lib/config.test.ts @@ -29,6 +29,7 @@ const setup = async ({ targetAllPlatforms = true }: { targetAllPlatforms?: boole return await Config.create({ isRelease: true, targetAllPlatforms, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/lib/config.ts b/src/dev/build/lib/config.ts index 650af04dfd54b..2bab1d28f9ef7 100644 --- a/src/dev/build/lib/config.ts +++ b/src/dev/build/lib/config.ts @@ -17,6 +17,7 @@ interface Options { isRelease: boolean; targetAllPlatforms: boolean; versionQualifier?: string; + dockerCrossCompile: boolean; dockerTagQualifier: string | null; dockerPush: boolean; } @@ -35,6 +36,7 @@ export class Config { isRelease, targetAllPlatforms, versionQualifier, + dockerCrossCompile, dockerTagQualifier, dockerPush, }: Options) { @@ -51,6 +53,7 @@ export class Config { versionQualifier, pkg, }), + dockerCrossCompile, dockerTagQualifier, dockerPush, isRelease @@ -63,6 +66,7 @@ export class Config { private readonly nodeVersion: string, private readonly repoRoot: string, private readonly versionInfo: VersionInfo, + private readonly dockerCrossCompile: boolean, private readonly dockerTagQualifier: string | null, private readonly dockerPush: boolean, public readonly isRelease: boolean @@ -96,6 +100,13 @@ export class Config { return this.dockerPush; } + /** + * Get docker cross compile + */ + getDockerCrossCompile() { + return this.dockerCrossCompile; + } + /** * Convert an absolute path to a relative path, based from the repo */ diff --git a/src/dev/build/lib/runner.test.ts b/src/dev/build/lib/runner.test.ts index 7c49c35446833..94ff3cb338176 100644 --- a/src/dev/build/lib/runner.test.ts +++ b/src/dev/build/lib/runner.test.ts @@ -50,6 +50,7 @@ const setup = async () => { isRelease: true, targetAllPlatforms: true, versionQualifier: '-SNAPSHOT', + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/download_cloud_dependencies.ts b/src/dev/build/tasks/download_cloud_dependencies.ts index 6ecc09c21ddce..31873550f6b4a 100644 --- a/src/dev/build/tasks/download_cloud_dependencies.ts +++ b/src/dev/build/tasks/download_cloud_dependencies.ts @@ -20,18 +20,24 @@ export const DownloadCloudDependencies: Task = { const version = config.getBuildVersion(); const buildId = id.match(/[0-9]\.[0-9]\.[0-9]-[0-9a-z]{8}/); const buildIdUrl = buildId ? `${buildId[0]}/` : ''; - const architecture = process.arch === 'arm64' ? 'arm64' : 'x86_64'; - const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${architecture}.tar.gz`; - const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); - const destination = config.resolveFromRepo('.beats', Path.basename(url)); - return downloadToDisk({ - log, - url, - destination, - shaChecksum: checksum.split(' ')[0], - shaAlgorithm: 'sha512', - maxAttempts: 3, + + const localArchitecture = [process.arch === 'arm64' ? 'arm64' : 'x86_64']; + const allArchitectures = ['arm64', 'x86_64']; + const architectures = config.getDockerCrossCompile() ? allArchitectures : localArchitecture; + const downloads = architectures.map(async (arch) => { + const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${arch}.tar.gz`; + const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); + const destination = config.resolveFromRepo('.beats', Path.basename(url)); + return downloadToDisk({ + log, + url, + destination, + shaChecksum: checksum.split(' ')[0], + shaAlgorithm: 'sha512', + maxAttempts: 3, + }); }); + return Promise.all(downloads); }; let buildId = ''; diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts index b1309bd05c603..c3b9cd5f8c6b1 100644 --- a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts @@ -39,6 +39,7 @@ async function setup({ failOnUrl }: { failOnUrl?: string } = {}) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts index fb0891c24f3b0..0041829984aa7 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -43,6 +43,7 @@ async function setup() { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts index 3a71a2b06fe91..85458c29ddcff 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -48,6 +48,7 @@ async function setup(actualShaSums?: Record) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 332605e926537..3152f07628fc9 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -76,6 +76,7 @@ export async function runDockerGenerator( const dockerPush = config.getDockerPush(); const dockerTagQualifier = config.getDockerTagQualfiier(); + const dockerCrossCompile = config.getDockerCrossCompile(); const publicArtifactSubdomain = config.isRelease ? 'artifacts' : 'snapshots-no-kpi'; const scope: TemplateContext = { @@ -110,7 +111,7 @@ export async function runDockerGenerator( arm64: 'aarch64', }; const buildArchitectureSupported = hostTarget[process.arch] === flags.architecture; - if (flags.architecture && !buildArchitectureSupported) { + if (flags.architecture && !buildArchitectureSupported && !dockerCrossCompile) { return; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index de26705566585..a14de2a0581ff 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -23,6 +23,7 @@ function generator({ const dockerTargetName = `${imageTag}${imageFlavor}:${version}${ dockerTagQualifier ? '-' + dockerTagQualifier : '' }`; + const dockerArchitecture = architecture === 'aarch64' ? 'linux/arm64' : 'linux/amd64'; return dedent(` #!/usr/bin/env bash # @@ -59,7 +60,7 @@ function generator({ retry_docker_pull ${baseOSImage} echo "Building: kibana${imageFlavor}-docker"; \\ - docker build -t ${dockerTargetName} -f Dockerfile . || exit 1; + docker buildx build --platform ${dockerArchitecture} -t ${dockerTargetName} -f Dockerfile . || exit 1; docker save ${dockerTargetName} | gzip -c > ${dockerTargetFilename} From cab3041613b3015cd3399d27ca4673cdedb4d1c7 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Mar 2022 17:12:46 -0400 Subject: [PATCH 30/66] [Fleet] Do not allow to edit output for managed policies (#128298) --- .../agent_policy_advanced_fields/index.tsx | 2 + .../server/routes/agent_policy/handlers.ts | 4 +- .../fleet/server/services/agent_policy.ts | 23 +++++- .../server/services/preconfiguration.test.ts | 5 +- .../fleet/server/services/preconfiguration.ts | 15 +++- .../server/types/rest_spec/agent_policy.ts | 4 +- .../apis/agent_policy/agent_policy.ts | 77 +++++++++++++------ .../apis/package_policy/delete.ts | 2 + 8 files changed, 102 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 1ba7f09d0333d..9fdcc0f73297f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -309,6 +309,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = isInvalid={Boolean(touchedFields.data_output_id && validation.data_output_id)} > = isInvalid={Boolean(touchedFields.monitoring_output_id && validation.monitoring_output_id)} > , - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; force?: boolean } ): Promise { if (agentPolicy.name) { await this.requireUniqueName(soClient, { @@ -352,6 +354,23 @@ class AgentPolicyService { name: agentPolicy.name, }); } + + const existingAgentPolicy = await this.get(soClient, id, true); + + if (!existingAgentPolicy) { + throw new Error('Agent policy not found'); + } + + if (existingAgentPolicy.is_managed && !options?.force) { + Object.entries(agentPolicy) + .filter(([key]) => !KEY_EDITABLE_FOR_MANAGED_POLICIES.includes(key)) + .forEach(([key, val]) => { + if (!isEqual(existingAgentPolicy[key as keyof AgentPolicy], val)) { + throw new HostedAgentPolicyRestrictionRelatedError(`Cannot update ${key}`); + } + }); + } + return this._update(soClient, esClient, id, agentPolicy, options?.user); } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 27919d7bf1011..862b589896793 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -689,7 +689,10 @@ describe('policy preconfiguration', () => { name: 'Renamed Test policy', description: 'Renamed Test policy description', unenroll_timeout: 999, - }) + }), + { + force: true, + } ); expect(policies.length).toEqual(1); expect(policies[0].id).toBe('test-id'); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 6f8c8bbc6a20d..c11925fa8f2f3 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -159,7 +159,10 @@ export async function ensurePreconfiguredPackagesAndPolicies( soClient, esClient, String(preconfiguredAgentPolicy.id), - fields + fields, + { + force: true, + } ); return { created, policy: updatedPolicy }; } @@ -254,7 +257,15 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); + await agentPolicyService.update( + soClient, + esClient, + policy!.id, + { is_managed: true }, + { + force: true, + } + ); } } } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts index 64d142f150bfd..042129e1e0914 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts @@ -32,7 +32,9 @@ export const CreateAgentPolicyRequestSchema = { export const UpdateAgentPolicyRequestSchema = { ...GetOneAgentPolicyRequestSchema, - body: NewAgentPolicySchema, + body: NewAgentPolicySchema.extends({ + force: schema.maybe(schema.boolean()), + }), }; export const CopyAgentPolicyRequestSchema = { diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 0e3cd9796626d..6c2c2c7bc8b48 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -478,6 +478,38 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('should return a 409 if policy already exists with name given', async () => { + const sharedBody = { + name: 'Initial name', + description: 'Initial description', + namespace: 'default', + }; + + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(200); + + const { body } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + + // same name, different namespace + sharedBody.namespace = 'different'; + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + }); + it('sets given is_managed value', async () => { const { body: { item: createdPolicy }, @@ -504,6 +536,7 @@ export default function (providerContext: FtrProviderContext) { name: 'TEST2', namespace: 'default', is_managed: false, + force: true, }) .expect(200); @@ -513,36 +546,33 @@ export default function (providerContext: FtrProviderContext) { expect(policy2.is_managed).to.equal(false); }); - it('should return a 409 if policy already exists with name given', async () => { - const sharedBody = { - name: 'Initial name', - description: 'Initial description', - namespace: 'default', - }; - - await supertest + it('should return a 400 if trying to update a managed policy', async () => { + const { + body: { item: originalPolicy }, + } = await supertest .post(`/api/fleet/agent_policies`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) + .send({ + name: `Managed policy ${Date.now()}`, + description: 'Initial description', + namespace: 'default', + is_managed: true, + }) .expect(200); const { body } = await supertest - .post(`/api/fleet/agent_policies`) + .put(`/api/fleet/agent_policies/${originalPolicy.id}`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); - - expect(body.message).to.match(/already exists?/); - - // same name, different namespace - sharedBody.namespace = 'different'; - await supertest - .post(`/api/fleet/agent_policies`) - .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); + .send({ + name: 'Updated name', + description: 'Initial description', + namespace: 'default', + }) + .expect(400); - expect(body.message).to.match(/already exists?/); + expect(body.message).to.equal( + 'Cannot update name in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.' + ); }); }); @@ -586,6 +616,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Regular policy', namespace: 'default', is_managed: false, + force: true, }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts index 5a5fb68a1dbc7..1f7377ba189ba 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -48,6 +48,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Test policy', namespace: 'default', is_managed: false, + force: true, }); } } @@ -138,6 +139,7 @@ export default function (providerContext: FtrProviderContext) { name: agentPolicy.name, namespace: agentPolicy.namespace, is_managed: false, + force: true, }) .expect(200); }); From 2f9e6eeacfac1d53a8ed91c8d7ddfe845a4e12cd Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 23 Mar 2022 22:16:50 +0100 Subject: [PATCH 31/66] [Lens] Manual Annotations (#126456) * Add event annotation service structure * adding annotation layer to lens. passing event annotation service * simplify initial Dimensions * add annotations to lens * no datasource layer * group the annotations into numerical icons * color icons in tooltip, add the annotation icon, fix date interval bug * display old time axis for annotations * error in annotation dimension when date histogram is removed * refactor: use the same methods for annotations and reference lines * wip * only check activeData for dataLayers * added new icons for annotations * refactor icons * uniqueLabels * unique Labels * diff config from args * change timestamp format * added expression event_annotation_group * names refactor * ea service adding help descriptions * rotate icon * added tests * fix button problem * dnd problem * dnd fix * tests for dimension trigger * tests for unique labels * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' * type * add new button test * remove noDatasource from config (only needed when initializing a layer or dimension in getSupportedLayers) * addressing Joe's and Michael comments * remove hexagon and square, address Stratoula's feedback * stroke for icons & icon fill * fix tests * fix small things * align the set with tsvb * align IconSelect * fix i18nrc * Update src/plugins/event_annotation/public/event_annotation_service/index.tsx Co-authored-by: Alexey Antonov * refactor empty button * CR * date cr * remove DimensionEditorSection * change to emptyShade for traingle fill * Update x-pack/plugins/lens/public/app_plugin/app.scss Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov --- .i18nrc.json | 17 +- docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/event_annotation/README.md | 3 + .../common/event_annotation_group/index.ts | 52 +++ src/plugins/event_annotation/common/index.ts | 13 + .../common/manual_event_annotation/index.ts | 82 +++++ .../common/manual_event_annotation/types.ts | 15 + src/plugins/event_annotation/common/types.ts | 29 ++ src/plugins/event_annotation/jest.config.js | 18 + src/plugins/event_annotation/kibana.json | 17 + .../public/event_annotation_service/README.md | 3 + .../event_annotation_service/helpers.ts | 9 + .../public/event_annotation_service/index.tsx | 20 ++ .../event_annotation_service/service.tsx | 49 +++ .../public/event_annotation_service/types.ts | 14 + src/plugins/event_annotation/public/index.ts | 17 + src/plugins/event_annotation/public/mocks.ts | 12 + src/plugins/event_annotation/public/plugin.ts | 39 +++ src/plugins/event_annotation/server/index.ts | 10 + src/plugins/event_annotation/server/plugin.ts | 30 ++ src/plugins/event_annotation/tsconfig.json | 22 ++ x-pack/plugins/lens/common/constants.ts | 1 + .../layer_config/annotation_layer_config.ts | 67 ++++ .../xy_chart/layer_config/index.ts | 7 +- .../common/expressions/xy_chart/xy_args.ts | 5 +- .../common/expressions/xy_chart/xy_chart.ts | 8 +- x-pack/plugins/lens/kibana.json | 3 +- .../plugins/lens/public/app_plugin/app.scss | 7 + .../public/assets/annotation_icons/circle.tsx | 31 ++ .../public/assets/annotation_icons/index.tsx | 9 + .../assets/annotation_icons/triangle.tsx | 30 ++ .../public/assets/chart_bar_annotations.tsx | 37 ++ .../buttons/draggable_dimension_button.tsx | 5 +- .../buttons/drop_targets_utils.tsx | 12 +- .../buttons/empty_dimension_button.tsx | 79 +++-- .../config_panel/config_panel.test.tsx | 75 +++- .../config_panel/config_panel.tsx | 94 +++--- .../config_panel/layer_panel.test.tsx | 82 +++-- .../editor_frame/config_panel/layer_panel.tsx | 233 +++++++------ x-pack/plugins/lens/public/expressions.ts | 2 + .../dimension_editor.tsx | 136 ++++---- .../droppable/get_drop_props.ts | 13 +- .../indexpattern.test.ts | 4 - .../indexpattern_datasource/indexpattern.tsx | 2 +- .../lens/public/pie_visualization/toolbar.tsx | 24 +- x-pack/plugins/lens/public/plugin.ts | 14 +- .../shared_components/dimension_section.scss | 24 ++ .../shared_components/dimension_section.tsx | 29 ++ .../lens/public/shared_components/index.ts | 1 + .../public/state_management/lens_slice.ts | 89 +++-- x-pack/plugins/lens/public/types.ts | 39 ++- .../visualizations/gauge/visualization.tsx | 6 - .../__snapshots__/expression.test.tsx.snap | 213 ++++++++++++ .../annotations/config_panel/icon_set.ts | 97 ++++++ .../annotations/config_panel/index.scss | 3 + .../annotations/config_panel/index.tsx | 186 ++++++++++ .../annotations/expression.scss | 37 ++ .../annotations/expression.tsx | 233 +++++++++++++ .../annotations/helpers.test.ts | 210 ++++++++++++ .../xy_visualization/annotations/helpers.tsx | 240 +++++++++++++ .../xy_visualization/annotations_helpers.tsx | 253 ++++++++++++++ .../xy_visualization/color_assignment.ts | 35 +- .../xy_visualization/expression.test.tsx | 238 +++++++++++-- .../public/xy_visualization/expression.tsx | 110 ++++-- .../expression_reference_lines.tsx | 228 ++----------- .../lens/public/xy_visualization/index.ts | 7 +- .../reference_line_helpers.tsx | 24 +- .../public/xy_visualization/state_helpers.ts | 5 +- .../xy_visualization/to_expression.test.ts | 2 + .../public/xy_visualization/to_expression.ts | 179 +++++++--- .../xy_visualization/visualization.test.ts | 319 +++++++++++++++++- .../public/xy_visualization/visualization.tsx | 111 ++++-- .../visualization_helpers.tsx | 41 ++- .../xy_config_panel/color_picker.tsx | 9 +- .../xy_config_panel/layer_header.tsx | 16 +- .../xy_config_panel/reference_line_panel.tsx | 1 + .../xy_config_panel/shared/icon_select.tsx | 32 +- .../shared/line_style_settings.tsx | 7 +- .../shared/marker_decoration_settings.tsx | 33 +- .../xy_visualization/xy_suggestions.test.ts | 58 +++- .../public/xy_visualization/xy_suggestions.ts | 5 +- .../lens/server/expressions/expressions.ts | 2 + x-pack/plugins/lens/tsconfig.json | 110 ++++-- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 87 files changed, 3885 insertions(+), 806 deletions(-) create mode 100644 src/plugins/event_annotation/README.md create mode 100644 src/plugins/event_annotation/common/event_annotation_group/index.ts create mode 100644 src/plugins/event_annotation/common/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/types.ts create mode 100644 src/plugins/event_annotation/common/types.ts create mode 100644 src/plugins/event_annotation/jest.config.js create mode 100644 src/plugins/event_annotation/kibana.json create mode 100644 src/plugins/event_annotation/public/event_annotation_service/README.md create mode 100644 src/plugins/event_annotation/public/event_annotation_service/helpers.ts create mode 100644 src/plugins/event_annotation/public/event_annotation_service/index.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/service.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/types.ts create mode 100644 src/plugins/event_annotation/public/index.ts create mode 100644 src/plugins/event_annotation/public/mocks.ts create mode 100644 src/plugins/event_annotation/public/plugin.ts create mode 100644 src/plugins/event_annotation/server/index.ts create mode 100644 src/plugins/event_annotation/server/plugin.ts create mode 100644 src/plugins/event_annotation/tsconfig.json create mode 100644 x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/index.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx create mode 100644 x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.scss create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/icon_set.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx diff --git a/.i18nrc.json b/.i18nrc.json index 402932902f249..71b68d2c51d85 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -31,6 +31,7 @@ "expressions": "src/plugins/expressions", "expressionShape": "src/plugins/expression_shape", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", + "eventAnnotation": "src/plugins/event_annotation", "fieldFormats": "src/plugins/field_formats", "flot": "packages/kbn-flot-charts/lib", "home": "src/plugins/home", @@ -50,7 +51,10 @@ "kibana-react": "src/plugins/kibana_react", "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", - "management": ["src/legacy/core_plugins/management", "src/plugins/management"], + "management": [ + "src/legacy/core_plugins/management", + "src/plugins/management" + ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", @@ -62,8 +66,13 @@ "sharedUX": "src/plugins/shared_ux", "sharedUXComponents": "packages/kbn-shared-ux-components/src", "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], - "timelion": ["src/plugins/vis_types/timelion"], + "telemetry": [ + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" + ], + "timelion": [ + "src/plugins/vis_types/timelion" + ], "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", @@ -83,4 +92,4 @@ "visualizations": "src/plugins/visualizations" }, "translations": [] -} +} \ No newline at end of file diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index bf81ab1e0bec4..aefaf4eab40fa 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -94,6 +94,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. +|{kib-repo}blob/{branch}/src/plugins/event_annotation/README.md[eventAnnotation] +|The Event Annotation service contains expressions for event annotations + + |{kib-repo}blob/{branch}/src/plugins/expression_error/README.md[expressionError] |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 396ffd4599284..526c1ff5dad82 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,3 +124,4 @@ pageLoadAssetSize: sessionView: 77750 cloudSecurityPosture: 19109 visTypeGauge: 24113 + eventAnnotation: 19334 diff --git a/src/plugins/event_annotation/README.md b/src/plugins/event_annotation/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/common/event_annotation_group/index.ts b/src/plugins/event_annotation/common/event_annotation_group/index.ts new file mode 100644 index 0000000000000..85f1d9dff900c --- /dev/null +++ b/src/plugins/event_annotation/common/event_annotation_group/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationOutput } from '../manual_event_annotation/types'; + +export interface EventAnnotationGroupOutput { + type: 'event_annotation_group'; + annotations: EventAnnotationOutput[]; +} + +export interface EventAnnotationGroupArgs { + annotations: EventAnnotationOutput[]; +} + +export function eventAnnotationGroup(): ExpressionFunctionDefinition< + 'event_annotation_group', + null, + EventAnnotationGroupArgs, + EventAnnotationGroupOutput +> { + return { + name: 'event_annotation_group', + aliases: [], + type: 'event_annotation_group', + inputTypes: ['null'], + help: i18n.translate('eventAnnotation.group.description', { + defaultMessage: 'Event annotation group', + }), + args: { + annotations: { + types: ['manual_event_annotation'], + help: i18n.translate('eventAnnotation.group.args.annotationConfigs', { + defaultMessage: 'Annotation configs', + }), + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'event_annotation_group', + annotations: args.annotations, + }; + }, + }; +} diff --git a/src/plugins/event_annotation/common/index.ts b/src/plugins/event_annotation/common/index.ts new file mode 100644 index 0000000000000..332fa19150aad --- /dev/null +++ b/src/plugins/event_annotation/common/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { EventAnnotationArgs, EventAnnotationOutput } from './manual_event_annotation/types'; +export { manualEventAnnotation } from './manual_event_annotation'; +export { eventAnnotationGroup } from './event_annotation_group'; +export type { EventAnnotationGroupArgs } from './event_annotation_group'; +export type { EventAnnotationConfig } from './types'; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/index.ts b/src/plugins/event_annotation/common/manual_event_annotation/index.ts new file mode 100644 index 0000000000000..108df93b34180 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/index.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationArgs, EventAnnotationOutput } from './types'; +export const manualEventAnnotation: ExpressionFunctionDefinition< + 'manual_event_annotation', + null, + EventAnnotationArgs, + EventAnnotationOutput +> = { + name: 'manual_event_annotation', + aliases: [], + type: 'manual_event_annotation', + help: i18n.translate('eventAnnotation.manualAnnotation.description', { + defaultMessage: `Configure manual annotation`, + }), + inputTypes: ['null'], + args: { + time: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.time', { + defaultMessage: `Timestamp for annotation`, + }), + }, + label: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.label', { + defaultMessage: `The name of the annotation`, + }), + }, + color: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.color', { + defaultMessage: 'The color of the line', + }), + }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineStyle', { + defaultMessage: 'The style of the annotation line', + }), + }, + lineWidth: { + types: ['number'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineWidth', { + defaultMessage: 'The width of the annotation line', + }), + }, + icon: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.icon', { + defaultMessage: 'An optional icon used for annotation lines', + }), + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.textVisibility', { + defaultMessage: 'Visibility of the label on the annotation line', + }), + }, + isHidden: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.isHidden', { + defaultMessage: `Switch to hide annotation`, + }), + }, + }, + fn: function fn(input: unknown, args: EventAnnotationArgs) { + return { + type: 'manual_event_annotation', + ...args, + }; + }, +}; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/types.ts b/src/plugins/event_annotation/common/manual_event_annotation/types.ts new file mode 100644 index 0000000000000..e1bed4a592d23 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/types.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StyleProps } from '../types'; + +export type EventAnnotationArgs = { + time: string; +} & StyleProps; + +export type EventAnnotationOutput = EventAnnotationArgs & { type: 'manual_event_annotation' }; diff --git a/src/plugins/event_annotation/common/types.ts b/src/plugins/event_annotation/common/types.ts new file mode 100644 index 0000000000000..95275804d1d1f --- /dev/null +++ b/src/plugins/event_annotation/common/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type AnnotationType = 'manual'; +export type KeyType = 'point_in_time'; + +export interface StyleProps { + label: string; + color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + textVisibility?: boolean; + isHidden?: boolean; +} + +export type EventAnnotationConfig = { + id: string; + key: { + type: KeyType; + timestamp: string; + }; +} & StyleProps; diff --git a/src/plugins/event_annotation/jest.config.js b/src/plugins/event_annotation/jest.config.js new file mode 100644 index 0000000000000..a6ea4a6b430df --- /dev/null +++ b/src/plugins/event_annotation/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/event_annotation'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/event_annotation', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/event_annotation/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/event_annotation/kibana.json b/src/plugins/event_annotation/kibana.json new file mode 100644 index 0000000000000..5a0c49be09ba3 --- /dev/null +++ b/src/plugins/event_annotation/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "eventAnnotation", + "version": "kibana", + "server": true, + "ui": true, + "description": "The Event Annotation service contains expressions for event annotations", + "extraPublicDirs": [ + "common" + ], + "requiredPlugins": [ + "expressions" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + } +} \ No newline at end of file diff --git a/src/plugins/event_annotation/public/event_annotation_service/README.md b/src/plugins/event_annotation/public/event_annotation_service/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/public/event_annotation_service/helpers.ts b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts new file mode 100644 index 0000000000000..aed33da840574 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { euiLightVars } from '@kbn/ui-theme'; +export const defaultAnnotationColor = euiLightVars.euiColorAccent; diff --git a/src/plugins/event_annotation/public/event_annotation_service/index.tsx b/src/plugins/event_annotation/public/event_annotation_service/index.tsx new file mode 100644 index 0000000000000..e967a7cb0f0a2 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServiceType } from './types'; + +export class EventAnnotationService { + private eventAnnotationService?: EventAnnotationServiceType; + public async getService() { + if (!this.eventAnnotationService) { + const { getEventAnnotationService } = await import('./service'); + this.eventAnnotationService = getEventAnnotationService(); + } + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/service.tsx b/src/plugins/event_annotation/public/event_annotation_service/service.tsx new file mode 100644 index 0000000000000..3d81ea6a3e3a6 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/service.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServiceType } from './types'; +import { defaultAnnotationColor } from './helpers'; + +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'empty'; +} + +export function getEventAnnotationService(): EventAnnotationServiceType { + return { + toExpression: ({ + label, + isHidden, + color, + lineStyle, + lineWidth, + icon, + textVisibility, + time, + }) => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'manual_event_annotation', + arguments: { + time: [time], + label: [label], + color: [color || defaultAnnotationColor], + lineWidth: [lineWidth || 1], + lineStyle: [lineStyle || 'solid'], + icon: hasIcon(icon) ? [icon] : ['triangle'], + textVisibility: [textVisibility || false], + isHidden: [Boolean(isHidden)], + }, + }, + ], + }; + }, + }; +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/types.ts b/src/plugins/event_annotation/public/event_annotation_service/types.ts new file mode 100644 index 0000000000000..bb0b6eb4cc200 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionAstExpression } from '../../../expressions/common/ast'; +import { EventAnnotationArgs } from '../../common'; + +export interface EventAnnotationServiceType { + toExpression: (props: EventAnnotationArgs) => ExpressionAstExpression; +} diff --git a/src/plugins/event_annotation/public/index.ts b/src/plugins/event_annotation/public/index.ts new file mode 100644 index 0000000000000..c15429c94cbe4 --- /dev/null +++ b/src/plugins/event_annotation/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + +import { EventAnnotationPlugin } from './plugin'; +export const plugin = () => new EventAnnotationPlugin(); +export type { EventAnnotationPluginSetup, EventAnnotationPluginStart } from './plugin'; +export * from './event_annotation_service/types'; +export { EventAnnotationService } from './event_annotation_service'; +export { defaultAnnotationColor } from './event_annotation_service/helpers'; diff --git a/src/plugins/event_annotation/public/mocks.ts b/src/plugins/event_annotation/public/mocks.ts new file mode 100644 index 0000000000000..e78d4e8f75de7 --- /dev/null +++ b/src/plugins/event_annotation/public/mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getEventAnnotationService } from './event_annotation_service/service'; + +// not really mocking but avoiding async loading +export const eventAnnotationServiceMock = getEventAnnotationService(); diff --git a/src/plugins/event_annotation/public/plugin.ts b/src/plugins/event_annotation/public/plugin.ts new file mode 100644 index 0000000000000..83cdc0546a7f5 --- /dev/null +++ b/src/plugins/event_annotation/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../expressions/public'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { EventAnnotationService } from './event_annotation_service'; + +interface SetupDependencies { + expressions: ExpressionsSetup; +} + +/** @public */ +export type EventAnnotationPluginSetup = EventAnnotationService; + +/** @public */ +export type EventAnnotationPluginStart = EventAnnotationService; + +/** @public */ +export class EventAnnotationPlugin + implements Plugin +{ + private readonly eventAnnotationService = new EventAnnotationService(); + + public setup(core: CoreSetup, dependencies: SetupDependencies): EventAnnotationPluginSetup { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + return this.eventAnnotationService; + } + + public start(): EventAnnotationPluginStart { + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/server/index.ts b/src/plugins/event_annotation/server/index.ts new file mode 100644 index 0000000000000..d9d13045ed10a --- /dev/null +++ b/src/plugins/event_annotation/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServerPlugin } from './plugin'; +export const plugin = () => new EventAnnotationServerPlugin(); diff --git a/src/plugins/event_annotation/server/plugin.ts b/src/plugins/event_annotation/server/plugin.ts new file mode 100644 index 0000000000000..ef4e0216fb5ac --- /dev/null +++ b/src/plugins/event_annotation/server/plugin.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, Plugin } from 'kibana/server'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { ExpressionsServerSetup } from '../../expressions/server'; + +interface SetupDependencies { + expressions: ExpressionsServerSetup; +} + +export class EventAnnotationServerPlugin implements Plugin { + public setup(core: CoreSetup, dependencies: SetupDependencies) { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/event_annotation/tsconfig.json b/src/plugins/event_annotation/tsconfig.json new file mode 100644 index 0000000000000..ca3d65a13b214 --- /dev/null +++ b/src/plugins/event_annotation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { + "path": "../../core/tsconfig.json" + }, + { + "path": "../expressions/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 1504e33ecacab..d0bfecbd386be 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -45,6 +45,7 @@ export const LegendDisplay = { export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', + ANNOTATIONS: 'annotations', } as const; // might collide with user-supplied field names, try to make as unique as possible diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts new file mode 100644 index 0000000000000..45b4bf31c0cdc --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EventAnnotationConfig, + EventAnnotationOutput, +} from '../../../../../../../src/plugins/event_annotation/common'; +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { layerTypes } from '../../../constants'; + +export interface XYAnnotationLayerConfig { + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + annotations: EventAnnotationConfig[]; + hide?: boolean; +} + +export interface AnnotationLayerArgs { + annotations: EventAnnotationOutput[]; + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + hide?: boolean; +} +export type XYAnnotationLayerArgsResult = AnnotationLayerArgs & { + type: 'lens_xy_annotation_layer'; +}; +export function annotationLayerConfig(): ExpressionFunctionDefinition< + 'lens_xy_annotation_layer', + null, + AnnotationLayerArgs, + XYAnnotationLayerArgsResult +> { + return { + name: 'lens_xy_annotation_layer', + aliases: [], + type: 'lens_xy_annotation_layer', + inputTypes: ['null'], + help: 'Annotation layer in lens', + args: { + layerId: { + types: ['string'], + help: '', + }, + layerType: { types: ['string'], options: [layerTypes.ANNOTATIONS], help: '' }, + hide: { + types: ['boolean'], + default: false, + help: 'Show details', + }, + annotations: { + types: ['manual_event_annotation'], + help: '', + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'lens_xy_annotation_layer', + ...args, + }; + }, + }; +} diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts index 0b27ce7d6ed85..df27229bdb81f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts @@ -6,7 +6,12 @@ */ import { XYDataLayerConfig } from './data_layer_config'; import { XYReferenceLineLayerConfig } from './reference_line_layer_config'; +import { XYAnnotationLayerConfig } from './annotation_layer_config'; export * from './data_layer_config'; export * from './reference_line_layer_config'; +export * from './annotation_layer_config'; -export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig; +export type XYLayerConfig = + | XYDataLayerConfig + | XYReferenceLineLayerConfig + | XYAnnotationLayerConfig; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 940896a2079e6..4520f0c99c3e9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -9,13 +9,14 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from '. import type { FittingFunction } from './fitting_function'; import type { EndValue } from './end_value'; import type { GridlinesConfigResult } from './grid_lines_config'; -import type { DataLayerArgs } from './layer_config'; +import type { AnnotationLayerArgs, DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; import type { TickLabelsConfigResult } from './tick_labels_config'; import type { LabelsOrientationConfigResult } from './labels_orientation_config'; import type { ValueLabelConfig } from '../../types'; export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; +export type XYLayerArgs = DataLayerArgs | AnnotationLayerArgs; // Arguments to XY chart expression, with computed properties export interface XYArgs { @@ -28,7 +29,7 @@ export interface XYArgs { yRightExtent: AxisExtentConfigResult; legend: LegendConfigResult; valueLabels: ValueLabelConfig; - layers: DataLayerArgs[]; + layers: XYLayerArgs[]; fittingFunction?: FittingFunction; endValue?: EndValue; emphasizeFitting?: boolean; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index d0f278d382be9..6d73e8eb9ba5f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -128,8 +128,12 @@ export const xyChart: ExpressionFunctionDefinition< }), }, layers: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any, + types: [ + 'lens_xy_data_layer', + 'lens_xy_referenceLine_layer', + 'lens_xy_annotation_layer', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, help: 'Layers of visual series', multi: true, }, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 17a58a0f96770..18f33adf40840 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -21,7 +21,8 @@ "presentationUtil", "dataViewFieldEditor", "expressionGauge", - "expressionHeatmap" + "expressionHeatmap", + "eventAnnotation" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 83b0a39be9229..5e859c1a93818 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,6 +38,13 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } +.lensAnnotationIconNoFill { + fill: none; +} + +.lensAnnotationIconFill { + fill: $euiColorGhost; +} // Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. .lnsNavItem__goBack { diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx new file mode 100644 index 0000000000000..fe19dc7e4c8fc --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconCircle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx new file mode 100644 index 0000000000000..9e641d495582f --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { IconCircle } from './circle'; +export { IconTriangle } from './triangle'; diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx new file mode 100644 index 0000000000000..9924c049004cf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx new file mode 100644 index 0000000000000..63fc9023533f6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarAnnotations = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index e88b04588d2e0..f0e0911b708fd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -18,6 +18,7 @@ import { getCustomDropTarget, getAdditionalClassesOnDroppable, getAdditionalClassesOnEnter, + getDropProps, } from './drop_targets_utils'; export function DraggableDimensionButton({ @@ -59,8 +60,8 @@ export function DraggableDimensionButton({ }) { const { dragging } = useContext(DragContext); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId, filterOperations: group.filterOperations, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx index 7d92eb9d22cbb..a293af4d11bfe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -9,7 +9,7 @@ import React from 'react'; import classNames from 'classnames'; import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DropType } from '../../../../types'; +import { Datasource, DropType, GetDropProps } from '../../../../types'; function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') { switch (type) { @@ -129,3 +129,13 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => { return 'lnsDragDrop-notCompatible'; } }; + +export const getDropProps = ( + layerDatasource: Datasource, + layerDatasourceDropProps: GetDropProps +) => { + if (layerDatasource) { + return layerDatasource.getDropProps(layerDatasourceDropProps); + } + return; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index 1ba3ff8f6ac34..f2118bda216b8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -14,7 +14,11 @@ import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; import { LayerDatasourceDropProps } from '../types'; -import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getDropProps, +} from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', @@ -24,32 +28,47 @@ interface EmptyButtonProps { columnId: string; onClick: (id: string) => void; group: VisualizationDimensionGroupConfig; + labels?: { + ariaLabel: (label: string) => string; + label: JSX.Element | string; + }; } -const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( - + i18n.translate('xpack.lens.indexPattern.addColumnAriaLabel', { defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, - })} - data-test-subj="lns-empty-dimension" - onClick={() => { - onClick(columnId); - }} - > + values: { groupLabel: l }, + }), + label: ( - -); + ), +}; + +const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => { + const { buttonAriaLabel, buttonLabel } = group.labels || {}; + return ( + { + onClick(columnId); + }} + > + {buttonLabel || defaultButtonLabels.label} + + ); +}; const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( contentProps={{ className: 'lnsLayerPanel__triggerTextContent', }} - aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', { - defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, + aria-label={i18n.translate('xpack.lens.indexPattern.suggestedValueAriaLabel', { + defaultMessage: 'Suggested value: {value} for {groupLabel}', + values: { value: group.suggestedValue?.(), groupLabel: group.groupLabel }, })} data-test-subj="lns-empty-dimension-suggested-value" onClick={() => { @@ -112,8 +131,8 @@ export function EmptyDimensionButton({ setNewColumnId(generateId()); }, [itemIndex]); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId: newColumnId, filterOperations: group.filterOperations, @@ -151,6 +170,12 @@ export function EmptyDimensionButton({ [value, onDrop] ); + const buttonProps: EmptyButtonProps = { + columnId: value.columnId, + onClick, + group, + }; + return (
    {typeof group.suggestedValue?.() === 'number' ? ( - + ) : ( - + )}
    diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index cd26cd3197587..b234b18f5262f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -20,7 +20,7 @@ import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; -import { layerTypes } from '../../../../common'; +import { LayerType, layerTypes } from '../../../../common'; import { ReactWrapper } from 'enzyme'; import { addLayer } from '../../../state_management'; @@ -231,14 +231,17 @@ describe('ConfigPanel', () => { }); describe('initial default value', () => { - function clickToAddLayer(instance: ReactWrapper) { + function clickToAddLayer( + instance: ReactWrapper, + layerType: LayerType = layerTypes.REFERENCELINE + ) { act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); instance.update(); act(() => { instance - .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.REFERENCELINE}"]`) + .find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`) .first() .simulate('click'); }); @@ -288,8 +291,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -319,8 +320,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -335,9 +334,7 @@ describe('ConfigPanel', () => { expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith({}, 'newId', { columnId: 'myColumn', - dataType: 'number', groupId: 'testGroup', - label: 'Initial value', staticValue: 100, }); }); @@ -354,8 +351,6 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -374,11 +369,65 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, } ); }); + + it('When visualization is `noDatasource` should not run datasource methods', async () => { + const datasourceMap = mockDatasourceMap(); + + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.setDimension = jest.fn(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.REFERENCELINE, + label: 'Reference layer', + }, + { + type: layerTypes.ANNOTATIONS, + label: 'Annotations Layer', + noDatasource: true, + initialDimensions: [ + { + groupId: 'a', + columnId: 'newId', + staticValue: 100, + }, + ], + }, + ]); + + datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ visualizationMap, datasourceMap }); + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance, layerTypes.ANNOTATIONS); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + + expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({ + columnId: 'newId', + frame: { + activeData: undefined, + datasourceLayers: { + a: expect.anything(), + }, + }, + groupId: 'a', + layerId: 'newId', + prevState: undefined, + }); + expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index d3574abe4f57a..163d1b8ce8e61 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -135,61 +135,57 @@ export function LayerPanels( [dispatchLens] ); - const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; - return ( - {layerIds.map((layerId, layerIndex) => - datasourcePublicAPIs[layerId] ? ( - { - // avoid state update if the datasource does not support initializeDimension - if ( - activeDatasourceId != null && - datasourceMap[activeDatasourceId]?.initializeDimension - ) { - dispatchLens( - setLayerDefaultDimension({ - layerId, - columnId, - groupId, - }) - ); - } - }} - onRemoveLayer={() => { + {layerIds.map((layerId, layerIndex) => ( + { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { dispatchLens( - removeOrClearLayer({ - visualizationId: activeVisualization.id, + setLayerDefaultDimension({ layerId, - layerIds, + columnId, + groupId, }) ); - removeLayerRef(layerId); - }} - toggleFullscreen={toggleFullscreen} - /> - ) : null - )} + } + }} + onRemoveLayer={() => { + dispatchLens( + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId, + layerIds, + }) + ); + removeLayerRef(layerId); + }} + toggleFullscreen={toggleFullscreen} + /> + ))} Hello!, + style: {}, + }, +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + mockDatasource = createMockDatasource('testDatasource'); let frame: FramePublicAPI; function getDefaultProps() { @@ -611,17 +623,6 @@ describe('LayerPanel', () => { nextLabel: '', }); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -666,17 +667,6 @@ describe('LayerPanel', () => { columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -985,4 +975,52 @@ describe('LayerPanel', () => { ); }); }); + describe('dimension trigger', () => { + it('should render datasource dimension trigger if there is layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).toHaveBeenCalled(); + }); + + it('should render visualization dimension trigger if there is no layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const props = getDefaultProps(); + const propsWithVisOnlyLayer = { + ...props, + framePublicAPI: { ...props.framePublicAPI, datasourceLayers: {} }, + }; + + mockVisualization.renderDimensionTrigger = jest.fn(); + mockVisualization.getUniqueLabels = jest.fn(() => ({ + x: 'A', + })); + + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled(); + expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 404a40832fc2f..366d3f93bf842 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -81,10 +81,10 @@ export function LayerPanel( updateDatasourceAsync, visualizationState, } = props; - const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; - const dateRange = useLensSelector(selectResolvedDateRange); + const datasourceStates = useLensSelector(selectDatasourceStates); const isFullscreen = useLensSelector(selectIsFullscreenDatasource); + const dateRange = useLensSelector(selectResolvedDateRange); useEffect(() => { setActiveDimension(initialActiveDimensionState); @@ -104,8 +104,10 @@ export function LayerPanel( activeData: props.framePublicAPI.activeData, }; - const datasourceId = datasourcePublicAPI.datasourceId; - const layerDatasourceState = datasourceStates[datasourceId].state; + const datasourcePublicAPI = framePublicAPI.datasourceLayers?.[layerId]; + const datasourceId = datasourcePublicAPI?.datasourceId; + const layerDatasourceState = datasourceStates?.[datasourceId]?.state; + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceDropProps = useMemo( () => ({ @@ -118,12 +120,9 @@ export function LayerPanel( [layerId, layerDatasourceState, datasourceId, updateDatasource] ); - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceConfigProps = { ...layerDatasourceDropProps, frame: props.framePublicAPI, - activeData: props.framePublicAPI.activeData, dateRange, }; @@ -137,11 +136,15 @@ export function LayerPanel( activeVisualization, ] ); + + const columnLabelMap = + !layerDatasource && activeVisualization.getUniqueLabels + ? activeVisualization.getUniqueLabels(props.visualizationState) + : layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state); + const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); const { activeId, activeGroup } = activeDimension; - const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); - const { setDimension, removeDimension } = activeVisualization; const allAccessors = groups.flatMap((group) => @@ -154,7 +157,7 @@ export function LayerPanel( registerNewRef: registerNewButtonRef, } = useFocusUpdate(allAccessors); - const layerDatasourceOnDrop = layerDatasource.onDrop; + const layerDatasourceOnDrop = layerDatasource?.onDrop; const onDrop = useMemo(() => { return ( @@ -180,16 +183,18 @@ export function LayerPanel( const filterOperations = group?.filterOperations || (() => false); - const dropResult = layerDatasourceOnDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId, - layerId: targetLayerId, - filterOperations, - dimensionGroups: groups, - groupId, - dropType, - }); + const dropResult = layerDatasource + ? layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + layerId: targetLayerId, + filterOperations, + dimensionGroups: groups, + groupId, + dropType, + }) + : false; if (dropResult) { let previousColumn = typeof droppedItem.column === 'string' ? droppedItem.column : undefined; @@ -241,6 +246,7 @@ export function LayerPanel( removeDimension, layerDatasourceDropProps, setNextFocusedButtonId, + layerDatasource, ]); const isDimensionPanelOpen = Boolean(activeId); @@ -340,43 +346,45 @@ export function LayerPanel( /> - {layerDatasource && ( - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, - layerId, - }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ + <> + + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, layerId, - columnId, - prevState: nextVisState, - frame: framePublicAPI, }); - }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ + layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, + }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> + )} @@ -401,7 +409,6 @@ export function LayerPanel( : i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', { defaultMessage: 'Requires field', }); - const isOptional = !group.required && !group.suggestedValue; return ( {group.accessors.map((accessorConfig, accessorIndex) => { const { columnId } = accessorConfig; - return ( { setActiveDimension({ @@ -478,42 +484,66 @@ export function LayerPanel( }} onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); + if (datasourceId && layerDatasource) { + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } else { + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } removeButtonRef(id); }} invalid={ - !layerDatasource.isValidColumn( + layerDatasource && + !layerDatasource?.isValidColumn( layerDatasourceState, layerId, columnId ) } > - + {layerDatasource ? ( + + ) : ( + <> + {activeVisualization?.renderDimensionTrigger?.({ + columnId, + label: columnLabelMap[columnId], + hideTooltip, + invalid: group.invalid, + invalidMessage: group.invalidMessage, + })} + + )}
    @@ -536,7 +566,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: !group.supportStaticValue, + isNew: !group.supportStaticValue && Boolean(layerDatasource), }); }} onDrop={onDrop} @@ -555,22 +585,25 @@ export function LayerPanel( isFullscreen={isFullscreen} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { - if ( - layerDatasource.canCloseDimensionEditor && - !layerDatasource.canCloseDimensionEditor(layerDatasourceState) - ) { - return false; - } - if (layerDatasource.updateStateOnCloseDimension) { - const newState = layerDatasource.updateStateOnCloseDimension({ - state: layerDatasourceState, - layerId, - columnId: activeId!, - }); - if (newState) { - props.updateDatasource(datasourceId, newState); + if (layerDatasource) { + if ( + layerDatasource.canCloseDimensionEditor && + !layerDatasource.canCloseDimensionEditor(layerDatasourceState) + ) { + return false; + } + if (layerDatasource.updateStateOnCloseDimension) { + const newState = layerDatasource.updateStateOnCloseDimension({ + state: layerDatasourceState, + layerId, + columnId: activeId!, + }); + if (newState) { + props.updateDatasource(datasourceId, newState); + } } } + setActiveDimension(initialActiveDimensionState); if (isFullscreen) { toggleFullscreen(); @@ -579,7 +612,7 @@ export function LayerPanel( }} panel={
    - {activeGroup && activeId && ( + {activeGroup && activeId && layerDatasource && ( - + - - color)} - type={FIXED_PROGRESSION} - onClick={() => { - setIsPaletteOpen(!isPaletteOpen); - }} - /> - - - { - setIsPaletteOpen(!isPaletteOpen); - }} - size="xs" - flush="both" - > - {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { - defaultMessage: 'Edit', - })} - - setIsPaletteOpen(!isPaletteOpen)} - > - {activePalette && ( - { - // make sure to always have a list of stops - if (newPalette.params && !newPalette.params.stops) { - newPalette.params.stops = displayStops; - } - (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; - setState({ - ...state, - palette: newPalette as HeatmapVisualizationState['palette'], - }); - }} - /> - )} - - - - + + + color)} + type={FIXED_PROGRESSION} + onClick={() => { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + {activePalette && ( + { + // make sure to always have a list of stops + if (newPalette.params && !newPalette.params.stops) { + newPalette.params.stops = displayStops; + } + (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; + setState({ + ...state, + palette: newPalette as HeatmapVisualizationState['palette'], + }); + }} + /> + )} + + + + + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index f3c48bace4a5f..3318b8c30909e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -89,12 +89,13 @@ export function getDropProps(props: GetDropProps) { ) { const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; const targetColumn = state.layers[layerId].columns[columnId]; - const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; - const isSameGroup = groupId === dragging.groupId; if (isSameGroup) { - return getDropPropsForSameGroup(targetColumn); - } else if (filterOperations(sourceColumn)) { + return getDropPropsForSameGroup(!targetColumn); + } + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + + if (filterOperations(sourceColumn)) { return getDropPropsForCompatibleGroup( props.dimensionGroups, dragging.columnId, @@ -164,8 +165,8 @@ function getDropPropsForField({ return; } -function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): DropProps { - return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +function getDropPropsForSameGroup(isNew?: boolean): DropProps { + return !isNew ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; } function getDropPropsForCompatibleGroup( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index f19658d468d5f..6bdd41d8db631 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -2626,9 +2626,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', }) ).toBe(state); }); @@ -2655,9 +2653,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', staticValue: 0, // use a falsy value to check also this corner case }) ).toEqual({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index cf77d1c9c1cc2..d0b644e2bf9b4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -230,7 +230,7 @@ export function getIndexPatternDatasource({ }); }, - initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { + initializeDimension(state, layerId, { columnId, groupId, staticValue }) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; if (staticValue == null) { return state; diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 2c038b0937999..d1f16ac5f9c41 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -22,8 +22,12 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; -import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; -import { PalettePicker } from '../shared_components'; +import { + ToolbarPopover, + LegendSettingsPopover, + useDebouncedValue, + PalettePicker, +} from '../shared_components'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; @@ -298,14 +302,12 @@ export function DimensionEditor( } ) { return ( - <> - { - props.setState({ ...props.state, palette: newPalette }); - }} - /> - + { + props.setState({ ...props.state, palette: newPalette }); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 4d883c3a27c5e..d2bb7cdbb4344 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -33,6 +33,7 @@ import type { NavigationPublicPluginStart } from '../../../../src/plugins/naviga import type { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import type { GlobalSearchPluginSetup } from '../../global_search/public'; import type { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import type { EventAnnotationPluginSetup } from '../../../../src/plugins/event_annotation/public'; import type { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies { embeddable?: EmbeddableSetup; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; @@ -120,6 +122,7 @@ export interface LensPluginStartDependencies { visualizations: VisualizationsStart; embeddable: EmbeddableStart; charts: ChartsPluginStart; + eventAnnotation: EventAnnotationPluginSetup; savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; @@ -235,6 +238,7 @@ export class LensPlugin { embeddable, visualizations, charts, + eventAnnotation, globalSearch, usageCollection, }: LensPluginSetupDependencies @@ -251,7 +255,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - plugins.fieldFormats.deserialize + plugins.fieldFormats.deserialize, + eventAnnotation ); const visualizationMap = await this.editorFrameService!.loadVisualizations(); @@ -311,7 +316,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - deps.fieldFormats.deserialize + deps.fieldFormats.deserialize, + eventAnnotation ), ensureDefaultDataView(), ]); @@ -368,7 +374,8 @@ export class LensPlugin { charts: ChartsPluginSetup, expressions: ExpressionsServiceSetup, fieldFormats: FieldFormatsSetup, - formatFactory: FormatFactory + formatFactory: FormatFactory, + eventAnnotation: EventAnnotationPluginSetup ) { const { DatatableVisualization, @@ -402,6 +409,7 @@ export class LensPlugin { charts, editorFrame: editorFrameSetupInterface, formatFactory, + eventAnnotation, }; this.indexpatternDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.scss b/x-pack/plugins/lens/public/shared_components/dimension_section.scss new file mode 100644 index 0000000000000..7781c91785d67 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.scss @@ -0,0 +1,24 @@ +.lnsDimensionEditorSection { + padding-top: $euiSize; + padding-bottom: $euiSize; +} + +.lnsDimensionEditorSection:first-child { + padding-top: 0; +} + +.lnsDimensionEditorSection:first-child .lnsDimensionEditorSection__border { + display: none; +} + +.lnsDimensionEditorSection__border { + position: relative; + &:before { + content: ''; + position: absolute; + top: -$euiSize; + right: -$euiSize; + left: -$euiSize; + border-top: 1px solid $euiColorLightShade; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.tsx b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx new file mode 100644 index 0000000000000..d56e08db4b037 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import './dimension_section.scss'; + +export const DimensionEditorSection = ({ + children, + title, +}: { + title?: string; + children?: React.ReactNode | React.ReactNode[]; +}) => { + return ( +
    +
    + {title && ( + +

    {title}

    +
    + )} + {children} +
    + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 6140e54b43dc7..b2428532a72c9 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -17,5 +17,6 @@ export { LegendActionPopover } from './legend_action_popover'; export { NameInput } from './name_input'; export { ValueLabelsSettings } from './value_labels_settings'; export { AxisTitleSettings } from './axis_title_settings'; +export { DimensionEditorSection } from './dimension_section'; export * from './static_header'; export * from './vis_label'; diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 56ff89f506c85..959db8ca006fe 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -619,30 +619,39 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { return state; } - const activeDatasource = datasourceMap[state.activeDatasourceId]; const activeVisualization = visualizationMap[state.visualization.activeId]; - - const datasourceState = activeDatasource.insertLayer( - state.datasourceStates[state.activeDatasourceId].state, - layerId - ); - const visualizationState = activeVisualization.appendLayer!( state.visualization.state, layerId, layerType ); + const framePublicAPI = { + // any better idea to avoid `as`? + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }; + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const { noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; + + const datasourceState = + !noDatasource && activeDatasource + ? activeDatasource.insertLayer( + state.datasourceStates[state.activeDatasourceId].state, + layerId + ) + : state.datasourceStates[state.activeDatasourceId].state; + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ datasourceState, visualizationState, - framePublicAPI: { - // any better idea to avoid `as`? - activeData: state.activeData - ? (current(state.activeData) as TableInspectorAdapter) - : undefined, - datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), - }, + framePublicAPI, activeVisualization, activeDatasource, layerId, @@ -710,39 +719,49 @@ function addInitialValueIfAvailable({ framePublicAPI: FramePublicAPI; visualizationState: unknown; datasourceState: unknown; - activeDatasource: Datasource; + activeDatasource?: Datasource; activeVisualization: Visualization; layerId: string; layerType: string; columnId?: string; groupId?: string; }) { - const layerInfo = activeVisualization - .getSupportedLayers(visualizationState, framePublicAPI) - .find(({ type }) => type === layerType); + const { initialDimensions, noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; - if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + if (initialDimensions) { const info = groupId - ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) - : // pick the first available one if not passed - layerInfo.initialDimensions[0]; + ? initialDimensions.find(({ groupId: id }) => id === groupId) + : initialDimensions[0]; // pick the first available one if not passed if (info) { - return { - activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { - ...info, - columnId: columnId || info.columnId, - }), - activeVisualizationState: activeVisualization.setDimension({ - groupId: info.groupId, - layerId, - columnId: columnId || info.columnId, - prevState: visualizationState, - frame: framePublicAPI, - }), - }; + const activeVisualizationState = activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: visualizationState, + frame: framePublicAPI, + }); + + if (!noDatasource && activeDatasource?.initializeDimension) { + return { + activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { + ...info, + columnId: columnId || info.columnId, + }), + activeVisualizationState, + }; + } else { + return { + activeDatasourceState: datasourceState, + activeVisualizationState, + }; + } } } + return { activeDatasourceState: datasourceState, activeVisualizationState: visualizationState, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9bea94bd723d3..cfa23320dc561 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,12 @@ interface ChartSettings { }; } +export type GetDropProps = DatasourceDimensionDropProps & { + groupId: string; + dragging: DragContextState['dragging']; + prioritizedOperation?: string; +}; + /** * Interface for the datasource registry */ @@ -227,10 +233,8 @@ export interface Datasource { layerId: string, value: { columnId: string; - label: string; - dataType: string; - staticValue?: unknown; groupId: string; + staticValue?: unknown; } ) => T; @@ -251,11 +255,7 @@ export interface Datasource { props: DatasourceLayerPanelProps ) => ((cleanupElement: Element) => void) | void; getDropProps: ( - props: DatasourceDimensionDropProps & { - groupId: string; - dragging: DragContextState['dragging']; - prioritizedOperation?: string; - } + props: GetDropProps ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; /** @@ -585,6 +585,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportStaticValue?: boolean; paramEditorCustomProps?: ParamEditorCustomProps; supportFieldFormat?: boolean; + labels?: { buttonAriaLabel: string; buttonLabel: string }; }; interface VisualizationDimensionChangeProps { @@ -786,14 +787,13 @@ export interface Visualization { type: LayerType; label: string; icon?: IconType; + noDatasource?: boolean; disabled?: boolean; toolTipContent?: string; initialDimensions?: Array<{ - groupId: string; columnId: string; - dataType: string; - label: string; - staticValue: unknown; + groupId: string; + staticValue?: unknown; }>; }>; getLayerType: (layerId: string, state?: T) => LayerType | undefined; @@ -858,7 +858,20 @@ export interface Visualization { domElement: Element, props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; - + /** + * Renders dimension trigger. Used only for noDatasource layers + */ + renderDimensionTrigger?: (props: { + columnId: string; + label: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) => JSX.Element | null; + /** + * Creates map of columns ids and unique lables. Used only for noDatasource layers + */ + getUniqueLabels?: (state: T) => Record; /** * The frame will call this function on all visualizations at different times. The * main use cases where visualization suggestions are requested are: diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index e1885fafab5e0..1770bac893b67 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -399,22 +399,16 @@ export const getGaugeVisualization = ({ { groupId: 'min', columnId: generateId(), - dataType: 'number', - label: 'minAccessor', staticValue: minValue, }, { groupId: 'max', columnId: generateId(), - dataType: 'number', - label: 'maxAccessor', staticValue: maxValue, }, { groupId: 'goal', columnId: generateId(), - dataType: 'number', - label: 'goalAccessor', staticValue: goalValue, }, ] diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index 504a553c5a631..fdde8eb6ad3f2 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -1,5 +1,218 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xy_expression XYChart component annotations should render basic annotation 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations preserving the shared styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": Array [ + 9, + 3, + ], + "opacity": 1, + "stroke": "red", + "strokeWidth": 3, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations with default styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render simplified annotation when hide is true 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + exports[`xy_expression XYChart component it renders area 1`] = ` & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const isHorizontal = isHorizontalChart(state.layers); + + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: state, + onChange: setState, + }); + + const index = localState.layers.findIndex((l) => l.layerId === layerId); + const localLayer = localState.layers.find( + (l) => l.layerId === layerId + ) as XYAnnotationLayerConfig; + + const currentAnnotations = localLayer.annotations?.find((c) => c.id === accessor); + + const setAnnotations = useCallback( + (annotations: Partial | undefined) => { + if (annotations == null) { + return; + } + const newConfigs = [...(localLayer.annotations || [])]; + const existingIndex = newConfigs.findIndex((c) => c.id === accessor); + if (existingIndex !== -1) { + newConfigs[existingIndex] = { ...newConfigs[existingIndex], ...annotations }; + } else { + return; // that should never happen because annotations are created before annotations panel is opened + } + setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index)); + }, + [accessor, index, localState, localLayer, setLocalState] + ); + + return ( + <> + + { + if (date) { + setAnnotations({ + key: { + ...(currentAnnotations?.key || { type: 'point_in_time' }), + timestamp: date.toISOString(), + }, + }); + } + }} + label={i18n.translate('xpack.lens.xyChart.annotationDate', { + defaultMessage: 'Annotation date', + })} + /> + + + { + setAnnotations({ label: value }); + }} + /> + + + + setAnnotations({ isHidden: ev.target.checked })} + /> + + + ); +}; + +const ConfigPanelDatePicker = ({ + value, + label, + onChange, +}: { + value: moment.Moment; + label: string; + onChange: (val: moment.Moment | null) => void; +}) => { + return ( + + + + ); +}; + +const ConfigPanelHideSwitch = ({ + value, + onChange, +}: { + value: boolean; + onChange: (event: EuiSwitchEvent) => void; +}) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss new file mode 100644 index 0000000000000..fc2b1204bb1d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss @@ -0,0 +1,37 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: 1.5; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} + +.lnsXyAnnotationNumberIcon { + border-radius: $euiSize; + min-width: $euiSize; + height: $euiSize; + background-color: currentColor; +} + +.lnsXyAnnotationNumberIcon__text { + font-weight: 500; + font-size: 9px; + letter-spacing: -.5px; + line-height: 11px; +} + +.lnsXyAnnotationIcon_rotate90 { + transform: rotate(45deg); + transform-origin: center; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx new file mode 100644 index 0000000000000..c36488f29d238 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './expression.scss'; +import React from 'react'; +import { snakeCase } from 'lodash'; +import { + AnnotationDomainType, + AnnotationTooltipFormatter, + LineAnnotation, + Position, +} from '@elastic/charts'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { EventAnnotationArgs } from 'src/plugins/event_annotation/common'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import type { AnnotationLayerArgs } from '../../../common/expressions'; +import { hasIcon } from '../xy_config_panel/shared/icon_select'; +import { + mapVerticalToHorizontalPlacement, + LINES_MARKER_SIZE, + MarkerBody, + Marker, + AnnotationIcon, +} from '../annotations_helpers'; + +const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => { + if (!firstTimestamp || !minInterval) { + return timestamp; + } + return timestamp - ((timestamp - firstTimestamp) % minInterval); +}; + +export interface AnnotationsProps { + groupedAnnotations: CollectiveConfig[]; + formatter?: FieldFormat; + isHorizontal: boolean; + paddingMap: Partial>; + hide?: boolean; + minInterval?: number; + isBarChart?: boolean; +} + +interface CollectiveConfig extends EventAnnotationArgs { + roundedTimestamp: number; + axisMode: 'bottom'; + customTooltipDetails?: AnnotationTooltipFormatter | undefined; +} + +const groupVisibleConfigsByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number +) => { + return layers + .flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden)) + .reduce>((acc, current) => { + const roundedTimestamp = getRoundedTimestamp( + moment(current.time).valueOf(), + firstTimestamp, + minInterval + ); + return { + ...acc, + [roundedTimestamp]: acc[roundedTimestamp] ? [...acc[roundedTimestamp], current] : [current], + }; + }, {}); +}; + +const createCustomTooltipDetails = + ( + config: EventAnnotationArgs[], + formatter?: FieldFormat + ): AnnotationTooltipFormatter | undefined => + () => { + return ( +
    + {config.map(({ icon, label, time, color }) => ( +
    + + {hasIcon(icon) && ( + + + + )} + {label} + + {formatter?.convert(time) || String(time)} +
    + ))} +
    + ); + }; + +function getCommonProperty( + configArr: EventAnnotationArgs[], + propertyName: K, + fallbackValue: T +) { + const firstStyle = configArr[0][propertyName]; + if (configArr.every((config) => firstStyle === config[propertyName])) { + return firstStyle; + } + return fallbackValue; +} + +const getCommonStyles = (configArr: EventAnnotationArgs[]) => { + return { + color: getCommonProperty( + configArr, + 'color', + defaultAnnotationColor + ), + lineWidth: getCommonProperty(configArr, 'lineWidth', 1), + lineStyle: getCommonProperty(configArr, 'lineStyle', 'solid'), + textVisibility: getCommonProperty(configArr, 'textVisibility', false), + }; +}; + +export const getAnnotationsGroupedByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number, + formatter?: FieldFormat +) => { + const visibleGroupedConfigs = groupVisibleConfigsByInterval(layers, minInterval, firstTimestamp); + let collectiveConfig: CollectiveConfig; + return Object.entries(visibleGroupedConfigs).map(([roundedTimestamp, configArr]) => { + collectiveConfig = { + ...configArr[0], + roundedTimestamp: Number(roundedTimestamp), + axisMode: 'bottom', + }; + if (configArr.length > 1) { + const commonStyles = getCommonStyles(configArr); + collectiveConfig = { + ...collectiveConfig, + ...commonStyles, + icon: String(configArr.length), + customTooltipDetails: createCustomTooltipDetails(configArr, formatter), + }; + } + return collectiveConfig; + }); +}; + +export const Annotations = ({ + groupedAnnotations, + formatter, + isHorizontal, + paddingMap, + hide, + minInterval, + isBarChart, +}: AnnotationsProps) => { + return ( + <> + {groupedAnnotations.map((annotation) => { + const markerPositionVertical = Position.Top; + const markerPosition = isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + const id = snakeCase(annotation.label); + const { roundedTimestamp, time: exactTimestamp } = annotation; + const isGrouped = Boolean(annotation.customTooltipDetails); + const header = + formatter?.convert(isGrouped ? roundedTimestamp : exactTimestamp) || + moment(isGrouped ? roundedTimestamp : exactTimestamp).toISOString(); + const strokeWidth = annotation.lineWidth || 1; + return ( + + ) : undefined + } + markerBody={ + !hide ? ( + + ) : undefined + } + markerPosition={markerPosition} + dataValues={[ + { + dataValue: moment( + isBarChart && minInterval ? roundedTimestamp + minInterval / 2 : roundedTimestamp + ).valueOf(), + header, + details: annotation.label, + }, + ]} + customTooltipDetails={annotation.customTooltipDetails} + style={{ + line: { + strokeWidth, + stroke: annotation.color || defaultAnnotationColor, + dash: + annotation.lineStyle === 'dashed' + ? [strokeWidth * 3, strokeWidth] + : annotation.lineStyle === 'dotted' + ? [strokeWidth, strokeWidth] + : undefined, + opacity: 1, + }, + }} + /> + ); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts new file mode 100644 index 0000000000000..fbf13db7fa7a5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FramePublicAPI } from '../../types'; +import { getStaticDate } from './helpers'; + +describe('annotations helpers', () => { + describe('getStaticDate', () => { + it('should return `now` value on when nothing is configured', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-08T11:01:58.135Z').valueOf()); + expect(getStaticDate([], undefined)).toBe('2022-04-08T11:01:58.135Z'); + }); + it('should return `now` value on when there is no active data', () => { + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + undefined + ) + ).toBe('2022-04-08T11:01:58.135Z'); + }); + + it('should return timestamp value for single active data point', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1646002800000, + b: 1050, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-02-27T23:00:00.000Z'); + }); + + it('should correctly calculate middle value for active data', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-03-26T05:00:00.000Z'); + }); + + it('should calculate middle date point correctly for multiple layers', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + layerId2: { + type: 'datatable', + rows: [ + { + d: 1548206000000, + c: 19, + }, + { + d: 1548249200000, + c: 73, + }, + ], + columns: [ + { + id: 'd', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'c', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + { + layerId: 'layerId2', + accessors: ['c'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'd', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2020-08-24T12:06:40.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx new file mode 100644 index 0000000000000..321090c94241a --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { layerTypes } from '../../../common'; +import type { + XYDataLayerConfig, + XYAnnotationLayerConfig, + XYLayerConfig, +} from '../../../common/expressions'; +import type { FramePublicAPI, Visualization } from '../../types'; +import { isHorizontalChart } from '../state_helpers'; +import type { XYState } from '../types'; +import { + checkScaleOperation, + getAnnotationsLayers, + getAxisName, + getDataLayers, + isAnnotationsLayer, +} from '../visualization_helpers'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; +import { generateId } from '../../id_generator'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import { defaultAnnotationLabel } from './config_panel'; + +const MAX_DATE = 8640000000000000; +const MIN_DATE = -8640000000000000; + +export function getStaticDate( + dataLayers: XYDataLayerConfig[], + activeData: FramePublicAPI['activeData'] +) { + const fallbackValue = moment().toISOString(); + + const dataLayersId = dataLayers.map(({ layerId }) => layerId); + if ( + !activeData || + Object.entries(activeData) + .filter(([key]) => dataLayersId.includes(key)) + .every(([, { rows }]) => !rows || !rows.length) + ) { + return fallbackValue; + } + + const minDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const firstTimestamp = activeData[lId]?.rows?.[0]?.[xAccessor]; + return firstTimestamp && firstTimestamp < acc ? firstTimestamp : acc; + }, MAX_DATE); + + const maxDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const lastTimestamp = activeData[lId]?.rows?.[activeData?.[lId]?.rows?.length - 1]?.[xAccessor]; + return lastTimestamp && lastTimestamp > acc ? lastTimestamp : acc; + }, MIN_DATE); + const middleDate = (minDate + maxDate) / 2; + return moment(middleDate).toISOString(); +} + +export const getAnnotationsSupportedLayer = ( + state?: XYState, + frame?: Pick +) => { + const dataLayers = getDataLayers(state?.layers || []); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + const initialDimensions = + state && hasDateHistogram + ? [ + { + groupId: 'xAnnotations', + columnId: generateId(), + }, + ] + : undefined; + + return { + type: layerTypes.ANNOTATIONS, + label: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabel', { + defaultMessage: 'Annotations', + }), + icon: LensIconChartBarAnnotations, + disabled: !hasDateHistogram, + toolTipContent: !hasDateHistogram + ? i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }) + : undefined, + initialDimensions, + noDatasource: true, + }; +}; + +export const setAnnotationsDimension: Visualization['setDimension'] = ({ + prevState, + layerId, + columnId, + previousColumn, + frame, +}) => { + const foundLayer = prevState.layers.find((l) => l.layerId === layerId); + if (!foundLayer || !isAnnotationsLayer(foundLayer)) { + return prevState; + } + const dataLayers = getDataLayers(prevState.layers); + const newLayer = { ...foundLayer } as XYAnnotationLayerConfig; + + const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId); + const previousConfig = previousColumn + ? newLayer.annotations?.find(({ id }) => id === previousColumn) + : false; + if (!hasConfig) { + const newTimestamp = getStaticDate(dataLayers, frame?.activeData); + newLayer.annotations = [ + ...(newLayer.annotations || []), + { + label: defaultAnnotationLabel, + key: { + type: 'point_in_time', + timestamp: newTimestamp, + }, + icon: 'triangle', + ...previousConfig, + id: columnId, + }, + ]; + } + return { + ...prevState, + layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), + }; +}; + +export const getAnnotationsAccessorColorConfig = (layer: XYAnnotationLayerConfig) => { + return layer.annotations.map((annotation) => { + return { + columnId: annotation.id, + triggerIcon: annotation.isHidden ? ('invisible' as const) : ('color' as const), + color: annotation?.color || defaultAnnotationColor, + }; + }); +}; + +export const getAnnotationsConfiguration = ({ + state, + frame, + layer, +}: { + state: XYState; + frame: FramePublicAPI; + layer: XYAnnotationLayerConfig; +}) => { + const dataLayers = getDataLayers(state.layers); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + + const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }); + + const emptyButtonLabels = { + buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', { + defaultMessage: 'Add an annotation to {groupLabel}', + values: { groupLabel }, + }), + buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', { + defaultMessage: 'Add an annotation', + }), + }; + + return { + groups: [ + { + groupId: 'xAnnotations', + groupLabel, + accessors: getAnnotationsAccessorColorConfig(layer), + dataTestSubj: 'lnsXY_xAnnotationsPanel', + invalid: !hasDateHistogram, + invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }), + required: false, + requiresPreviousColumnOnDuplicate: true, + supportsMoreColumns: true, + supportFieldFormat: false, + enableDimensionEditor: true, + filterOperations: () => false, + labels: emptyButtonLabels, + }, + ], + }; +}; + +export const getUniqueLabels = (layers: XYLayerConfig[]) => { + const annotationLayers = getAnnotationsLayers(layers); + const columnLabelMap = {} as Record; + const counts = {} as Record; + + const makeUnique = (label: string) => { + let uniqueLabel = label; + + while (counts[uniqueLabel] >= 0) { + const num = ++counts[uniqueLabel]; + uniqueLabel = i18n.translate('xpack.lens.uniqueLabel', { + defaultMessage: '{label} [{num}]', + values: { label, num }, + }); + } + + counts[uniqueLabel] = 0; + return uniqueLabel; + }; + + annotationLayers.forEach((layer) => { + if (!layer.annotations) { + return; + } + layer.annotations.forEach((l) => { + columnLabelMap[l.id] = makeUnique(l.label); + }); + }); + return columnLabelMap; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx new file mode 100644 index 0000000000000..ddbdfc91f4a3e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import './expression_reference_lines.scss'; +import React from 'react'; +import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import classnames from 'classnames'; +import type { IconPosition, YAxisMode, YConfig } from '../../common/expressions'; +import { hasIcon } from './xy_config_panel/shared/icon_select'; +import { annotationsIconSet } from './annotations/config_panel/icon_set'; + +export const LINES_MARKER_SIZE = 20; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +// Note: it does not take into consideration whether the reference line is in view or not + +export const getLinesCausedPaddings = ( + visualConfigs: Array< + Pick | undefined + >, + axesMap: Record<'left' | 'right', unknown> +) => { + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + visualConfigs?.forEach((config) => { + if (!config) { + return; + } + const { axisMode, icon, iconPosition, textVisibility } = config; + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode); + paddings[placement] = Math.max( + paddings[placement] || 0, + LINES_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = LINES_MARKER_SIZE; + } + }); + return paddings; +}; + +export function mapVerticalToHorizontalPlacement(placement: Position) { + switch (placement) { + case Position.Top: + return Position.Right; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Bottom; + case Position.Right: + return Position.Top; + } +} + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export function MarkerBody({ + label, + isHorizontal, +}: { + label: string | undefined; + isHorizontal: boolean; +}) { + if (!label) { + return null; + } + if (isHorizontal) { + return ( +
    + {label} +
    + ); + } + return ( +
    +
    + {label} +
    +
    + ); +} + +const isNumericalString = (value: string) => !isNaN(Number(value)); + +function NumberIcon({ number }: { number: number }) { + return ( + + + {number < 10 ? number : `9+`} + + + ); +} + +interface MarkerConfig { + axisMode?: YAxisMode; + icon?: string; + textVisibility?: boolean; + iconPosition?: IconPosition; +} + +export const AnnotationIcon = ({ + type, + rotateClassName = '', + isHorizontal, + renderedInChart, + ...rest +}: { + type: string; + rotateClassName?: string; + isHorizontal?: boolean; + renderedInChart?: boolean; +} & EuiIconProps) => { + if (isNumericalString(type)) { + return ; + } + const iconConfig = annotationsIconSet.find((i) => i.value === type); + if (!iconConfig) { + return null; + } + return ( + + ); +}; + +export function Marker({ + config, + isHorizontal, + hasReducedPadding, + label, + rotateClassName, +}: { + config: MarkerConfig; + isHorizontal: boolean; + hasReducedPadding: boolean; + label?: string; + rotateClassName?: string; +}) { + if (hasIcon(config.icon)) { + return ( + + ); + } + + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (config.textVisibility) { + if (hasReducedPadding) { + return ; + } + return ; + } + return null; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 82c1106e72a08..f8d5805279a2e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -13,7 +13,9 @@ import type { AccessorConfig, FramePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import { FormatFactory, LayerType } from '../../common'; import type { XYLayerConfig } from '../../common/expressions'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer } from './visualization_helpers'; +import { getAnnotationsAccessorColorConfig } from './annotations/helpers'; +import { getReferenceLineAccessorColorConfig } from './reference_line_helpers'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -42,15 +44,13 @@ export function getColorAssignments( ): ColorAssignments { const layersPerPalette: Record = {}; - layers - .filter((layer) => isDataLayer(layer)) - .forEach((layer) => { - const palette = layer.palette?.name || 'default'; - if (!layersPerPalette[palette]) { - layersPerPalette[palette] = []; - } - layersPerPalette[palette].push(layer); - }); + layers.forEach((layer) => { + const palette = layer.palette?.name || 'default'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { @@ -102,17 +102,6 @@ export function getColorAssignments( }); } -const getReferenceLineAccessorColorConfig = (layer: XYLayerConfig) => { - return layer.accessors.map((accessor) => { - const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); - return { - columnId: accessor, - triggerIcon: 'color' as const, - color: currentYConfig?.color || defaultReferenceLineColor, - }; - }); -}; - export function getAccessorColorConfig( colorAssignments: ColorAssignments, frame: Pick, @@ -122,7 +111,9 @@ export function getAccessorColorConfig( if (isReferenceLayer(layer)) { return getReferenceLineAccessorColorConfig(layer); } - + if (isAnnotationsLayer(layer)) { + return getAnnotationsAccessorColorConfig(layer); + } const layerContainsSplits = Boolean(layer.splitAccessor); const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 654a0f1b94a14..03a180cc20a08 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -20,12 +20,13 @@ import { HorizontalAlignment, VerticalAlignment, LayoutDirection, + LineAnnotation, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps } from './expression'; import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; -import { xyChart } from '../../common/expressions'; +import { AnnotationLayerArgs, xyChart } from '../../common/expressions'; import { dataLayerConfig, legendConfig, @@ -41,12 +42,14 @@ import { } from '../../common/expressions'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { XyEndzones } from './x_domain'; +import { eventAnnotationServiceMock } from '../../../../../src/plugins/event_annotation/public/mocks'; +import { EventAnnotationOutput } from 'src/plugins/event_annotation/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -536,6 +539,7 @@ describe('xy_expression', () => { onSelectRange, syncColors: false, useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }; }); @@ -546,7 +550,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -613,7 +617,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time' }, + ], }} minInterval={undefined} /> @@ -802,7 +808,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time', isHistogram: true, @@ -878,7 +884,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', xScaleType: 'time', isHistogram: true, @@ -975,7 +981,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'area', }, ], @@ -1006,7 +1012,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', }, ], @@ -1083,7 +1089,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'linear' }, + ], }} /> ); @@ -1102,7 +1110,12 @@ describe('xy_expression', () => { args={{ ...args, layers: [ - { ...args.layers[0], seriesType: 'line', xScaleType: 'linear', isHistogram: true }, + { + ...(args.layers[0] as DataLayerArgs), + seriesType: 'line', + xScaleType: 'linear', + isHistogram: true, + }, ], }} /> @@ -1150,7 +1163,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1165,7 +1178,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1180,7 +1193,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1678,7 +1694,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1693,7 +1712,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1710,7 +1732,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_horizontal_stacked' }, + ], }} /> ); @@ -1732,7 +1756,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), xAccessor: undefined, splitAccessor: 'e', seriesType: 'bar_stacked', @@ -1762,7 +1786,7 @@ describe('xy_expression', () => { accessors: ['b'], seriesType: 'bar', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1772,7 +1796,11 @@ describe('xy_expression', () => { test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => { const { data, args } = sampleArgs(); - const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + const firstLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'bar', + isHistogram: true, + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1787,13 +1815,13 @@ describe('xy_expression', () => { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const secondLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete secondLayer.splitAccessor; const component = shallow( { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_stacked', isHistogram: true, }, @@ -1836,7 +1864,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', isHistogram: true }, + ], }} /> ); @@ -2232,7 +2262,10 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -2246,7 +2279,7 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -2268,7 +2301,7 @@ describe('xy_expression', () => { ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -2678,7 +2711,9 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), accessors: ['a'], splitAccessor: undefined }, + ], legend: { ...args.legend, isVisible: true, showSingleSeries: true }, }} /> @@ -2696,7 +2731,13 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { + ...(args.layers[0] as DataLayerArgs), + accessors: ['a'], + splitAccessor: undefined, + }, + ], legend: { ...args.legend, isVisible: true, isInside: true }, }} /> @@ -2782,7 +2823,7 @@ describe('xy_expression', () => { test('it should apply None fitting function if not specified', () => { const { data, args } = sampleArgs(); - args.layers[0].accessors = ['a']; + (args.layers[0] as DataLayerArgs).accessors = ['a']; const component = shallow( @@ -2920,6 +2961,139 @@ describe('xy_expression', () => { }, ]); }); + + describe('annotations', () => { + const sampleStyledAnnotation: EventAnnotationOutput = { + time: '2022-03-18T08:25:00.000Z', + label: 'Event 1', + icon: 'triangle', + type: 'manual_event_annotation', + color: 'red', + lineStyle: 'dashed', + lineWidth: 3, + }; + const sampleAnnotationLayers: AnnotationLayerArgs[] = [ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + time: '2022-03-18T08:25:17.140Z', + label: 'Annotation', + type: 'manual_event_annotation', + }, + ], + }, + ]; + function sampleArgsWithAnnotation(annotationLayers = sampleAnnotationLayers) { + const { args } = sampleArgs(); + return { + data: dateHistogramData, + args: { + ...args, + layers: [dateHistogramLayer, ...annotationLayers], + } as XYArgs, + }; + } + test('should render basic annotation', () => { + const { data, args } = sampleArgsWithAnnotation(); + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + test('should render simplified annotation when hide is true', () => { + const { data, args } = sampleArgsWithAnnotation(); + args.layers[0].hide = true; + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + + test('should render grouped annotations preserving the shared styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:25:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 3', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are passed because they are shared, dataValues & header is rounded to the interval + expect(groupedAnnotation).toMatchSnapshot(); + // renders numeric icon for grouped annotations + const marker = mount(
    {groupedAnnotation.prop('marker')}
    ); + const numberIcon = marker.find('NumberIcon'); + expect(numberIcon.length).toEqual(1); + expect(numberIcon.text()).toEqual('3'); + + // checking tooltip + const renderLinks = mount(
    {groupedAnnotation.prop('customTooltipDetails')!()}
    ); + expect(renderLinks.text()).toEqual( + ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z' + ); + }); + test('should render grouped annotations with default styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [sampleStyledAnnotation], + }, + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + ...sampleStyledAnnotation, + icon: 'square', + color: 'blue', + lineStyle: 'dotted', + lineWidth: 10, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 2', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are default because they are different for both annotations + expect(groupedAnnotation).toMatchSnapshot(); + }); + test('should not render hidden annotations', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:35:00.001Z', + label: 'Event 3', + isHidden: true, + }, + ], + }, + ]); + const component = mount(); + const annotations = component.find(LineAnnotation); + + expect(annotations.length).toEqual(2); + }); + }); }); describe('calculateMinInterval', () => { @@ -2927,7 +3101,7 @@ describe('xy_expression', () => { beforeEach(() => { xyProps = sampleArgs(); - xyProps.args.layers[0].xScaleType = 'time'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'time'; }); it('should use first valid layer and determine interval', async () => { xyProps.data.tables.first.columns[2].meta.source = 'esaggs'; @@ -2942,7 +3116,7 @@ describe('xy_expression', () => { }); it('should return interval of number histogram if available on first x axis columns', async () => { - xyProps.args.layers[0].xScaleType = 'linear'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'linear'; xyProps.data.tables.first.columns[2].meta = { source: 'esaggs', type: 'number', @@ -2984,7 +3158,7 @@ describe('xy_expression', () => { }); it('should return undefined if x axis is not a date', async () => { - xyProps.args.layers[0].xScaleType = 'ordinal'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'ordinal'; xyProps.data.tables.first.columns.splice(2, 1); const result = await calculateMinInterval(xyProps); expect(result).toEqual(undefined); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 72a3f5f4f6976..8b62b8d0c120c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -50,11 +50,17 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/public'; import { FieldFormat } from 'src/plugins/field_formats/common'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import type { + DataLayerArgs, + SeriesType, + XYChartProps, + XYLayerArgs, +} from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -72,13 +78,17 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; -import { - computeChartMargins, - getReferenceLineRequiredPaddings, - ReferenceLineAnnotations, -} from './expression_reference_lines'; +import { ReferenceLineAnnotations } from './expression_reference_lines'; + +import { computeChartMargins, getLinesCausedPaddings } from './annotations_helpers'; + +import { Annotations, getAnnotationsGroupedByInterval } from './annotations/expression'; import { computeOverallDataDomain } from './reference_line_helpers'; -import { getReferenceLayers, isDataLayer } from './visualization_helpers'; +import { + getReferenceLayers, + getDataLayersArgs, + getAnnotationsLayersArgs, +} from './visualization_helpers'; declare global { interface Window { @@ -104,6 +114,7 @@ export type XYChartRenderProps = XYChartProps & { onSelectRange: (data: LensBrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; + eventAnnotationService: EventAnnotationServiceType; }; export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { @@ -140,6 +151,7 @@ export const getXyChartRenderer = (dependencies: { timeZone: string; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; + eventAnnotationService: EventAnnotationServiceType; }): ExpressionRenderDefinition => ({ name: 'lens_xy_chart_renderer', displayName: 'XY chart', @@ -170,6 +182,7 @@ export const getXyChartRenderer = (dependencies: { chartsActiveCursorService={dependencies.chartsActiveCursorService} chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} + eventAnnotationService={dependencies.eventAnnotationService} timeZone={dependencies.timeZone} useLegacyTimeAxis={dependencies.useLegacyTimeAxis} minInterval={calculateMinInterval(config)} @@ -265,7 +278,9 @@ export function XYChart({ }); if (filteredLayers.length === 0) { - const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar'); + const icon: IconType = getIconForSeriesType( + getDataLayersArgs(layers)?.[0]?.seriesType || 'bar' + ); return ; } @@ -353,7 +368,23 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); - const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap); + const annotationsLayers = getAnnotationsLayersArgs(layers); + const firstTable = data.tables[filteredLayers[0].layerId]; + + const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id; + + const groupedAnnotations = getAnnotationsGroupedByInterval( + annotationsLayers, + minInterval, + xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined, + xAxisFormatter + ); + const visualConfigs = [ + ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...groupedAnnotations, + ].filter(Boolean); + + const linesPaddings = getLinesCausedPaddings(visualConfigs, yAxesMap); const getYAxesStyle = (groupId: 'left' | 'right') => { const tickVisible = @@ -369,9 +400,9 @@ export function XYChart({ ? args.labelsOrientation?.yRight || 0 : args.labelsOrientation?.yLeft || 0, padding: - referenceLinePaddings[groupId] != null + linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -382,9 +413,9 @@ export function XYChart({ : axisTitlesVisibilitySettings?.yLeft, // if labels are not visible add the padding to the title padding: - !tickVisible && referenceLinePaddings[groupId] != null + !tickVisible && linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -458,7 +489,7 @@ export function XYChart({ const valueLabelsStyling = shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate); - const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const colorAssignments = getColorAssignments(getDataLayersArgs(args.layers), data, formatFactory); const clickHandler: ElementClickListener = ([[geometry, series]]) => { // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue @@ -591,16 +622,13 @@ export function XYChart({ tickLabel: { visible: tickLabelsVisibilitySettings?.x, rotation: labelsOrientation?.x, - padding: - referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } - : undefined, + padding: linesPaddings.bottom != null ? { inner: linesPaddings.bottom } : undefined, }, axisTitle: { visible: axisTitlesVisibilitySettings.x, padding: - !tickLabelsVisibilitySettings?.x && referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } + !tickLabelsVisibilitySettings?.x && linesPaddings.bottom != null + ? { inner: linesPaddings.bottom } : undefined, }, }; @@ -633,7 +661,7 @@ export function XYChart({ chartMargins: { ...chartTheme.chartPaddings, ...computeChartMargins( - referenceLinePaddings, + linesPaddings, tickLabelsVisibilitySettings, axisTitlesVisibilitySettings, yAxesMap, @@ -1005,29 +1033,37 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} - paddingMap={referenceLinePaddings} + paddingMap={linesPaddings} + /> + ) : null} + {groupedAnnotations.length ? ( + 0} + minInterval={minInterval} /> ) : null}
    ); } -function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) { - return layers.filter((layer) => { +function getFilteredLayers(layers: XYLayerArgs[], data: LensMultiTable) { + return getDataLayersArgs(layers).filter((layer) => { const { layerId, xAccessor, accessors, splitAccessor } = layer; - return ( - isDataLayer(layer) && - !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) - ) + return !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index 2d22f6a6ed76e..7817db573e419 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -8,183 +8,19 @@ import './expression_reference_lines.scss'; import React from 'react'; import { groupBy } from 'lodash'; -import { EuiIcon } from '@elastic/eui'; import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions'; +import type { ReferenceLineLayerArgs } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; -import { hasIcon } from './xy_config_panel/shared/icon_select'; - -export const REFERENCE_LINE_MARKER_SIZE = 20; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// Note: it does not take into consideration whether the reference line is in view or not -export const getReferenceLineRequiredPaddings = ( - referenceLineLayers: ReferenceLineLayerArgs[], - axesMap: Record<'left' | 'right', unknown> -) => { - // collect all paddings for the 4 axis: if any text is detected double it. - const paddings: Partial> = {}; - const icons: Partial> = {}; - referenceLineLayers.forEach((layer) => { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { - if (axisMode && (hasIcon(icon) || textVisibility)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - paddings[placement] = Math.max( - paddings[placement] || 0, - REFERENCE_LINE_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text - ); - icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); - } - }); - }); - // post-process the padding based on the icon presence: - // if no icon is present for the placement, just reduce the padding - (Object.keys(paddings) as Position[]).forEach((placement) => { - if (!icons[placement]) { - paddings[placement] = REFERENCE_LINE_MARKER_SIZE; - } - }); - - return paddings; -}; - -function mapVerticalToHorizontalPlacement(placement: Position) { - switch (placement) { - case Position.Top: - return Position.Right; - case Position.Bottom: - return Position.Left; - case Position.Left: - return Position.Bottom; - case Position.Right: - return Position.Top; - } -} - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axisMode: YAxisMode | undefined, - axesMap: Record -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -function getMarkerBody(label: string | undefined, isHorizontal: boolean) { - if (!label) { - return; - } - if (isHorizontal) { - return ( -
    - {label} -
    - ); - } - return ( -
    -
    - {label} -
    -
    - ); -} - -interface MarkerConfig { - axisMode?: YAxisMode; - icon?: string; - textVisibility?: boolean; -} - -function getMarkerToShow( - markerConfig: MarkerConfig, - label: string | undefined, - isHorizontal: boolean, - hasReducedPadding: boolean -) { - // show an icon if present - if (hasIcon(markerConfig.icon)) { - return ; - } - // if there's some text, check whether to show it as marker, or just show some padding for the icon - if (markerConfig.textVisibility) { - if (hasReducedPadding) { - return getMarkerBody( - label, - (!isHorizontal && markerConfig.axisMode === 'bottom') || - (isHorizontal && markerConfig.axisMode !== 'bottom') - ); - } - return ; - } -} +import { defaultReferenceLineColor } from './color_assignment'; +import { + MarkerBody, + Marker, + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + getBaseIconPlacement, +} from './annotations_helpers'; export interface ReferenceLineAnnotationsProps { layers: ReferenceLineLayerArgs[]; @@ -241,32 +77,40 @@ export const ReferenceLineAnnotations = ({ const formatter = formatters[groupId || 'bottom']; - const defaultColor = euiLightVars.euiColorDarkShade; - // get the position for vertical chart const markerPositionVertical = getBaseIconPlacement( yConfig.iconPosition, - yConfig.axisMode, - axesMap + axesMap, + yConfig.axisMode ); // the padding map is built for vertical chart - const hasReducedPadding = - paddingMap[markerPositionVertical] === REFERENCE_LINE_MARKER_SIZE; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; const props = { groupId, - marker: getMarkerToShow( - yConfig, - columnToLabelMap[yConfig.forAccessor], - isHorizontal, - hasReducedPadding + marker: ( + ), - markerBody: getMarkerBody( - yConfig.textVisibility && !hasReducedPadding - ? columnToLabelMap[yConfig.forAccessor] - : undefined, - (!isHorizontal && yConfig.axisMode === 'bottom') || - (isHorizontal && yConfig.axisMode !== 'bottom') + markerBody: ( + ), // rotate the position if required markerPosition: isHorizontal @@ -284,7 +128,7 @@ export const ReferenceLineAnnotations = ({ const sharedStyle = { strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, + stroke: yConfig.color || defaultReferenceLineColor, dash: dashStyle, }; @@ -355,7 +199,7 @@ export const ReferenceLineAnnotations = ({ })} style={{ ...sharedStyle, - fill: yConfig.color || defaultColor, + fill: yConfig.color || defaultReferenceLineColor, opacity: 0.1, }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 9697ba149e16e..cfeb1387f689c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -6,6 +6,7 @@ */ import type { CoreSetup } from 'kibana/public'; +import { EventAnnotationPluginSetup } from '../../../../../src/plugins/event_annotation/public'; import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import type { EditorFrameSetup } from '../types'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -19,6 +20,7 @@ export interface XyVisualizationPluginSetupPlugins { formatFactory: FormatFactory; editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; } export class XyVisualization { @@ -28,8 +30,9 @@ export class XyVisualization { ) { editorFrame.registerVisualization(async () => { const { getXyChartRenderer, getXyVisualization } = await import('../async_services'); - const [, { charts, fieldFormats }] = await core.getStartServices(); + const [, { charts, fieldFormats, eventAnnotation }] = await core.getStartServices(); const palettes = await charts.palettes.getPalettes(); + const eventAnnotationService = await eventAnnotation.getService(); const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS); expressions.registerRenderer( getXyChartRenderer({ @@ -37,6 +40,7 @@ export class XyVisualization { chartsThemeService: charts.theme, chartsActiveCursorService: charts.activeCursor, paletteService: palettes, + eventAnnotationService, timeZone: getTimeZone(core.uiSettings), useLegacyTimeAxis, kibanaTheme: core.theme, @@ -44,6 +48,7 @@ export class XyVisualization { ); return getXyVisualization({ paletteService: palettes, + eventAnnotationService, fieldFormats, useLegacyTimeAxis, kibanaTheme: core.theme, diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index ac50a81da5423..8b6a96ce24d44 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -14,7 +14,7 @@ import type { YConfig, } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; -import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; +import type { DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; import { isHorizontalChart, isPercentageSeries, isStackedChart } from './state_helpers'; import type { XYState } from './types'; @@ -27,6 +27,7 @@ import { } from './visualization_helpers'; import { generateId } from '../id_generator'; import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line'; +import { defaultReferenceLineColor } from './color_assignment'; export interface ReferenceLineBase { label: 'x' | 'yRight' | 'yLeft'; @@ -360,18 +361,29 @@ export const setReferenceDimension: Visualization['setDimension'] = ({ }; }; +const getSingleColorConfig = (id: string, color = defaultReferenceLineColor) => ({ + columnId: id, + triggerIcon: 'color' as const, + color, +}); + +export const getReferenceLineAccessorColorConfig = (layer: XYReferenceLineLayerConfig) => { + return layer.accessors.map((accessor) => { + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + return getSingleColorConfig(accessor, currentYConfig?.color); + }); +}; + export const getReferenceConfiguration = ({ state, frame, layer, sortedAccessors, - mappedAccessors, }: { state: XYState; frame: FramePublicAPI; layer: XYReferenceLineLayerConfig; sortedAccessors: string[]; - mappedAccessors: AccessorConfig[]; }) => { const idToIndex = sortedAccessors.reduce>((memo, id, index) => { memo[id] = index; @@ -420,11 +432,7 @@ export const getReferenceConfiguration = ({ groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ groupId: id, groupLabel: getAxisName(label, { isHorizontal }), - accessors: config.map(({ forAccessor, color }) => ({ - columnId: forAccessor, - color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, - triggerIcon: 'color' as const, - })), + accessors: config.map(({ forAccessor, color }) => getSingleColorConfig(forAccessor, color)), filterOperations: isNumericMetric, supportsMoreColumns: true, required: false, diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index dee7899740173..e0984e62cb9cc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -16,7 +16,7 @@ import type { XYReferenceLineLayerConfig, } from '../../common/expressions'; import { visualizationTypes } from './types'; -import { getDataLayers, isDataLayer } from './visualization_helpers'; +import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -53,6 +53,9 @@ export function getIconForSeries(type: SeriesType): EuiIconType { } export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { + if (isAnnotationsLayer(layer)) { + return layer?.annotations?.find((ann) => ann.id === accessor)?.color || null; + } if (isDataLayer(layer) && layer.splitAccessor) { return null; } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index fa992d8829b20..2e3db8f2f6f93 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -15,6 +15,7 @@ import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { defaultReferenceLineColor } from './color_assignment'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; describe('#toExpression', () => { const xyVisualization = getXyVisualization({ @@ -22,6 +23,7 @@ describe('#toExpression', () => { fieldFormats: fieldFormatsServiceMock.createStartContract(), kibanaTheme: themeServiceMock.createStartContract(), useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index a9c166a9c13eb..ade90ff98e553 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -8,29 +8,40 @@ import { Ast } from '@kbn/interpreter'; import { ScaleType } from '@elastic/charts'; import { PaletteRegistry } from 'src/plugins/charts/public'; +import { EventAnnotationServiceType } from 'src/plugins/event_annotation/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, - XYDataLayerConfig, + XYAnnotationLayerConfig, XYReferenceLineLayerConfig, YConfig, + XYDataLayerConfig, } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/shared/icon_select'; import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; -import { isDataLayer } from './visualization_helpers'; +import { + getLayerTypeOptions, + getDataLayers, + getReferenceLayers, + getAnnotationsLayers, +} from './visualization_helpers'; +import { defaultAnnotationLabel } from './annotations/config_panel'; +import { getUniqueLabels } from './annotations/helpers'; export const getSortedAccessors = ( datasource: DatasourcePublicAPI, layer: XYDataLayerConfig | XYReferenceLineLayerConfig ) => { const originalOrder = datasource - .getTableSpec() - .map(({ columnId }: { columnId: string }) => columnId) - .filter((columnId: string) => layer.accessors.includes(columnId)); + ? datasource + .getTableSpec() + .map(({ columnId }: { columnId: string }) => columnId) + .filter((columnId: string) => layer.accessors.includes(columnId)) + : layer.accessors; // When we add a column it could be empty, and therefore have no order return Array.from(new Set(originalOrder.concat(layer.accessors))); }; @@ -39,7 +50,8 @@ export const toExpression = ( state: State, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { if (!state || !state.layers.length) { return null; @@ -49,38 +61,58 @@ export const toExpression = ( state.layers.forEach((layer) => { metadata[layer.layerId] = {}; const datasource = datasourceLayers[layer.layerId]; - datasource.getTableSpec().forEach((column) => { - const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); - metadata[layer.layerId][column.columnId] = operation; - }); + if (datasource) { + datasource.getTableSpec().forEach((column) => { + const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); + metadata[layer.layerId][column.columnId] = operation; + }); + } }); - return buildExpression(state, metadata, datasourceLayers, paletteService, attributes); + return buildExpression( + state, + metadata, + datasourceLayers, + paletteService, + attributes, + eventAnnotationService + ); +}; + +const simplifiedLayerExpression = { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => ({ ...layer, hide: true }), + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => ({ + ...layer, + hide: true, + yConfig: layer.yConfig?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => ({ + ...layer, + hide: true, + annotations: layer.annotations?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), }; export function toPreviewExpression( state: State, datasourceLayers: Record, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + eventAnnotationService: EventAnnotationServiceType ) { return toExpression( { ...state, - layers: state.layers.map((layer) => - isDataLayer(layer) - ? { ...layer, hide: true } - : // cap the reference line to 1px - { - ...layer, - hide: true, - yConfig: layer.yConfig?.map(({ lineWidth, ...config }) => ({ - ...config, - lineWidth: 1, - icon: undefined, - textVisibility: false, - })), - } - ), + layers: state.layers.map((layer) => getLayerTypeOptions(layer, simplifiedLayerExpression)), // hide legend for preview legend: { ...state.legend, @@ -90,7 +122,8 @@ export function toPreviewExpression( }, datasourceLayers, paletteService, - {} + {}, + eventAnnotationService ); } @@ -125,23 +158,35 @@ export const buildExpression = ( metadata: Record>, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { - const validLayers = state.layers + const validDataLayers = getDataLayers(state.layers) .filter((layer): layer is ValidLayer => Boolean(layer.accessors.length)) - .map((layer) => { - if (!datasourceLayers) { - return layer; - } - const sortedAccessors = getSortedAccessors(datasourceLayers[layer.layerId], layer); + .map((layer) => ({ + ...layer, + accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer), + })); + + // sorting doesn't change anything so we don't sort reference layers (TODO: should we make it work?) + const validReferenceLayers = getReferenceLayers(state.layers).filter((layer) => + Boolean(layer.accessors.length) + ); + const uniqueLabels = getUniqueLabels(state.layers); + const validAnnotationsLayers = getAnnotationsLayers(state.layers) + .filter((layer) => Boolean(layer.annotations.length)) + .map((layer) => { return { ...layer, - accessors: sortedAccessors, + annotations: layer.annotations.map((c) => ({ + ...c, + label: uniqueLabels[c.id], + })), }; }); - if (!validLayers.length) { + if (!validDataLayers.length) { return null; } @@ -309,20 +354,25 @@ export const buildExpression = ( valueLabels: [state?.valueLabels || 'hide'], hideEndzones: [state?.hideEndzones || false], valuesInLegend: [state?.valuesInLegend || false], - layers: validLayers.map((layer) => { - if (isDataLayer(layer)) { - return dataLayerToExpression( + layers: [ + ...validDataLayers.map((layer) => + dataLayerToExpression( layer, datasourceLayers[layer.layerId], metadata, paletteService - ); - } - return referenceLineLayerToExpression( - layer, - datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] - ); - }), + ) + ), + ...validReferenceLayers.map((layer) => + referenceLineLayerToExpression( + layer, + datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] + ) + ), + ...validAnnotationsLayers.map((layer) => + annotationLayerToExpression(layer, eventAnnotationService) + ), + ], }, }, ], @@ -355,6 +405,41 @@ const referenceLineLayerToExpression = ( }; }; +const annotationLayerToExpression = ( + layer: XYAnnotationLayerConfig, + eventAnnotationService: EventAnnotationServiceType +): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_annotation_layer', + arguments: { + hide: [Boolean(layer.hide)], + layerId: [layer.layerId], + layerType: [layerTypes.ANNOTATIONS], + annotations: layer.annotations + ? layer.annotations.map( + (ann): Ast => + eventAnnotationService.toExpression({ + time: ann.key.timestamp, + label: ann.label || defaultAnnotationLabel, + textVisibility: ann.textVisibility, + icon: ann.icon, + lineStyle: ann.lineStyle, + lineWidth: ann.lineWidth, + color: ann.color, + isHidden: Boolean(ann.isHidden), + }) + ) + : [], + }, + }, + ], + }; +}; + const dataLayerToExpression = ( layer: ValidLayer, datasourceLayer: DatasourcePublicAPI, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 07e411b1993c9..b93cf317e1b2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -8,7 +8,7 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; -import type { State, XYSuggestion } from './types'; +import type { State, XYState, XYSuggestion } from './types'; import type { SeriesType, XYDataLayerConfig, @@ -23,6 +23,18 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; +import { EventAnnotationConfig } from 'src/plugins/event_annotation/common'; + +const exampleAnnotation: EventAnnotationConfig = { + id: 'an1', + label: 'Event 1', + key: { + type: 'point_in_time', + timestamp: '2022-03-18T08:25:17.140Z', + }, + icon: 'circle', +}; function exampleState(): State { return { @@ -49,6 +61,7 @@ const xyVisualization = getXyVisualization({ fieldFormats: fieldFormatsMock, useLegacyTimeAxis: false, kibanaTheme: themeServiceMock.createStartContract(), + eventAnnotationService: eventAnnotationServiceMock, }); describe('xy_visualization', () => { @@ -149,7 +162,7 @@ describe('xy_visualization', () => { expect(initialState.layers).toHaveLength(1); expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined(); - expect(initialState.layers[0].accessors).toHaveLength(0); + expect((initialState.layers[0] as XYDataLayerConfig).accessors).toHaveLength(0); expect(initialState).toMatchInlineSnapshot(` Object { @@ -227,12 +240,63 @@ describe('xy_visualization', () => { describe('#getSupportedLayers', () => { it('should return a double layer types', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(2); + expect(xyVisualization.getSupportedLayers()).toHaveLength(3); }); it('should return the icon for the visualization type', () => { expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); }); + describe('annotations', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('when there is no date histogram annotation layer is disabled', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState()) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeTruthy(); + }); + it('for data with date histogram annotation layer is enabled and calculates initial dimensions', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState(), frame) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeFalsy(); + expect(supportedAnnotationLayer?.noDatasource).toBeTruthy(); + expect(supportedAnnotationLayer?.initialDimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'xAnnotations', columnId: expect.any(String) }), + ]) + ); + }); + }); }); describe('#getLayerType', () => { @@ -358,6 +422,45 @@ describe('xy_visualization', () => { ], }); }); + + describe('annotations', () => { + it('should add a dimension to a annotation layer', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-18T11:01:58.135Z').valueOf()); + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }, + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + exampleAnnotation, + { + icon: 'triangle', + id: 'newCol', + key: { + timestamp: '2022-04-18T11:01:58.135Z', + type: 'point_in_time', + }, + label: 'Event', + }, + ], + }); + }); + }); }); describe('#updateLayersConfigurationFromContext', () => { @@ -472,9 +575,10 @@ describe('xy_visualization', () => { layerId: 'first', context: newContext, }); - expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); - expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine'); - expect(state?.layers[0].yConfig).toStrictEqual([ + const firstLayer = state?.layers[0] as XYDataLayerConfig; + expect(firstLayer).toHaveProperty('seriesType', 'area'); + expect(firstLayer).toHaveProperty('layerType', 'referenceLine'); + expect(firstLayer.yConfig).toStrictEqual([ { axisMode: 'right', color: '#68BC00', @@ -695,6 +799,45 @@ describe('xy_visualization', () => { accessors: [], }); }); + it('removes annotation dimension', () => { + expect( + xyVisualization.removeDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }], + }, + ], + }, + layerId: 'ann', + columnId: 'an2', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ]); + }); }); describe('#getConfiguration', () => { @@ -1069,7 +1212,7 @@ describe('xy_visualization', () => { it('should support static value', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[1] as XYReferenceLineLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; expect( xyVisualization.getConfiguration({ @@ -1082,7 +1225,7 @@ describe('xy_visualization', () => { it('should return no referenceLine groups for a empty data layer', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; const options = xyVisualization.getConfiguration({ @@ -1358,6 +1501,83 @@ describe('xy_visualization', () => { }); }); + describe('annotations', () => { + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + + function getStateWithAnnotationLayer(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'annotations', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }; + } + + it('returns configuration correctly', () => { + const state = getStateWithAnnotationLayer(); + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].accessors).toEqual([ + { color: '#f04e98', columnId: 'an1', triggerIcon: 'color' }, + ]); + expect(config.groups[0].invalid).toEqual(false); + }); + + it('When data layer is empty, should return invalid state', () => { + const state = getStateWithAnnotationLayer(); + (state.layers[0] as XYDataLayerConfig).xAccessor = undefined; + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].invalid).toEqual(true); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); @@ -1954,4 +2174,87 @@ describe('xy_visualization', () => { `); }); }); + describe('#getUniqueLabels', () => { + it('creates unique labels for single annotations layer with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layerId', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + }); + }); + it('creates unique labels for multiple annotations layers with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layer1', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + { + layerId: 'layer2', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '4', + }, + { + label: 'Event [1]', + id: '5', + }, + { + label: 'Custom', + id: '6', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + '4': 'Event [2]', + '5': 'Event [1] [1]', + '6': 'Custom [1]', + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index c9951c24f8a47..78fd50f7cfece 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -13,16 +13,17 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; -import type { FillStyle } from '../../common/expressions/xy_chart'; +import type { FillStyle, XYLayerConfig } from '../../common/expressions/xy_chart'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; import { DimensionEditor } from './xy_config_panel/dimension_editor'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, AccessorConfig, FramePublicAPI } from '../types'; import { State, visualizationTypes, XYSuggestion } from './types'; -import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions'; +import { SeriesType, XYDataLayerConfig, YAxisMode } from '../../common/expressions'; import { layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -34,6 +35,12 @@ import { getReferenceSupportedLayer, setReferenceDimension, } from './reference_line_helpers'; +import { + getAnnotationsConfiguration, + getAnnotationsSupportedLayer, + setAnnotationsDimension, + getUniqueLabels, +} from './annotations/helpers'; import { checkXAccessorCompatibility, defaultSeriesType, @@ -42,7 +49,9 @@ import { getDescription, getFirstDataLayer, getLayersByType, + getReferenceLayers, getVisualizationType, + isAnnotationsLayer, isBucketed, isDataLayer, isNumericDynamicMetric, @@ -54,14 +63,18 @@ import { import { groupAxesByType } from './axes_configuration'; import { XYState } from '..'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel'; +import { DimensionTrigger } from '../shared_components/dimension_trigger'; +import { AnnotationsPanel, defaultAnnotationLabel } from './annotations/config_panel'; export const getXyVisualization = ({ paletteService, fieldFormats, useLegacyTimeAxis, kibanaTheme, + eventAnnotationService, }: { paletteService: PaletteRegistry; + eventAnnotationService: EventAnnotationServiceType; fieldFormats: FieldFormatsStart; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; @@ -155,7 +168,11 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { - return [supportedDataLayer, getReferenceSupportedLayer(state, frame)]; + return [ + supportedDataLayer, + getAnnotationsSupportedLayer(state, frame), + getReferenceSupportedLayer(state, frame), + ]; }, getConfiguration({ state, frame, layerId }) { @@ -164,10 +181,18 @@ export const getXyVisualization = ({ return { groups: [] }; } + if (isAnnotationsLayer(layer)) { + return getAnnotationsConfiguration({ state, frame, layer }); + } + const sortedAccessors: string[] = getSortedAccessors( frame.datasourceLayers[layer.layerId], layer ); + if (isReferenceLayer(layer)) { + return getReferenceConfiguration({ state, frame, layer, sortedAccessors }); + } + const mappedAccessors = getMappedAccessors({ state, frame, @@ -177,11 +202,7 @@ export const getXyVisualization = ({ accessors: sortedAccessors, }); - if (isReferenceLayer(layer)) { - return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors }); - } const dataLayers = getDataLayers(state.layers); - const isHorizontal = isHorizontalChart(state.layers); const { left, right } = groupAxesByType([layer], frame.activeData); // Check locally if it has one accessor OR one accessor per axis @@ -275,6 +296,9 @@ export const getXyVisualization = ({ if (isReferenceLayer(foundLayer)) { return setReferenceDimension(props); } + if (isAnnotationsLayer(foundLayer)) { + return setAnnotationsDimension(props); + } const newLayer = { ...foundLayer }; if (groupId === 'x') { @@ -295,7 +319,7 @@ export const getXyVisualization = ({ updateLayersConfigurationFromContext({ prevState, layerId, context }) { const { chartType, axisPosition, palette, metrics } = context; const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); - if (!foundLayer) { + if (!foundLayer || !isDataLayer(foundLayer)) { return prevState; } const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value'); @@ -377,7 +401,16 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } - const dataLayers = getDataLayers(prevState.layers); + if (isAnnotationsLayer(foundLayer)) { + const newLayer = { ...foundLayer }; + newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId); + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + return { + ...prevState, + layers: newLayers, + }; + } const newLayer = { ...foundLayer }; if (isDataLayer(newLayer)) { if (newLayer.xAccessor === columnId) { @@ -392,15 +425,15 @@ export const getXyVisualization = ({ newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } - if (newLayer.yConfig) { - newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + if ('yConfig' in newLayer) { + newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId); } let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); // check if there's any reference layer and pull it off if all data layers have no dimensions set // check for data layers if they all still have xAccessors const groupsAvailable = getGroupsAvailableInData( - dataLayers, + getDataLayers(prevState.layers), frame.datasourceLayers, frame?.activeData ); @@ -410,7 +443,9 @@ export const getXyVisualization = ({ (id) => !groupsAvailable[id] ) ) { - newLayers = newLayers.filter((layer) => isDataLayer(layer) || layer.accessors.length); + newLayers = newLayers.filter( + (layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length) + ); } return { @@ -450,9 +485,12 @@ export const getXyVisualization = ({ const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; const dimensionEditor = isReferenceLayer(layer) ? ( + ) : isAnnotationsLayer(layer) ? ( + ) : ( ); + render( {dimensionEditor} @@ -462,8 +500,9 @@ export const getXyVisualization = ({ }, toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression(state, layers, paletteService, attributes, eventAnnotationService), + toPreviewExpression: (state, layers) => + toPreviewExpression(state, layers, paletteService, eventAnnotationService), getErrorMessages(state, datasourceLayers) { // Data error handling below here @@ -504,7 +543,7 @@ export const getXyVisualization = ({ // temporary fix for #87068 errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); - for (const layer of state.layers) { + for (const layer of getDataLayers(state.layers)) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { for (const accessor of layer.accessors) { @@ -540,9 +579,10 @@ export const getXyVisualization = ({ return; } - const layers = state.layers; - - const filteredLayers = layers.filter(({ accessors }: XYLayerConfig) => accessors.length > 0); + const filteredLayers = [ + ...getDataLayers(state.layers), + ...getReferenceLayers(state.layers), + ].filter(({ accessors }) => accessors.length > 0); const accessorsWithArrayValues = []; for (const layer of filteredLayers) { const { layerId, accessors } = layer; @@ -569,6 +609,35 @@ export const getXyVisualization = ({ /> )); }, + getUniqueLabels(state) { + return getUniqueLabels(state.layers); + }, + renderDimensionTrigger({ + columnId, + label, + hideTooltip, + invalid, + invalidMessage, + }: { + columnId: string; + label?: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) { + if (label) { + return ( + + ); + } + return null; + }, }); const getMappedAccessors = ({ @@ -584,7 +653,7 @@ const getMappedAccessors = ({ paletteService: PaletteRegistry; fieldFormats: FieldFormatsStart; state: XYState; - layer: XYLayerConfig; + layer: XYDataLayerConfig; }) => { let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({ columnId: accessor, @@ -592,7 +661,7 @@ const getMappedAccessors = ({ if (frame.activeData) { const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, fieldFormats.deserialize ); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 7446c2a06119c..23c2446ca2363 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -11,8 +11,12 @@ import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../ty import { State, visualizationTypes, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { + AnnotationLayerArgs, + DataLayerArgs, SeriesType, + XYAnnotationLayerConfig, XYDataLayerConfig, + XYLayerArgs, XYLayerConfig, XYReferenceLineLayerConfig, } from '../../common/expressions'; @@ -130,9 +134,12 @@ export function checkScaleOperation( export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig => layer.layerType === layerTypes.DATA || !layer.layerType; -export const getDataLayers = (layers: XYLayerConfig[]) => +export const getDataLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer)); +export const getDataLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is DataLayerArgs => isDataLayer(layer)); + export const getFirstDataLayer = (layers: XYLayerConfig[]) => (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); @@ -140,9 +147,34 @@ export const isReferenceLayer = ( layer: Pick ): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE; -export const getReferenceLayers = (layers: XYLayerConfig[]) => +export const getReferenceLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); +export const isAnnotationsLayer = ( + layer: Pick +): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS; + +export const getAnnotationsLayers = (layers: Array>) => + (layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer)); + +export const getAnnotationsLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is AnnotationLayerArgs => isAnnotationsLayer(layer)); + +export interface LayerTypeToLayer { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig; + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig; + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig; +} + +export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => { + if (isDataLayer(layer)) { + return options[layerTypes.DATA](layer); + } else if (isReferenceLayer(layer)) { + return options[layerTypes.REFERENCELINE](layer); + } + return options[layerTypes.ANNOTATIONS](layer); +}; + export function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { return ( @@ -255,6 +287,11 @@ const newLayerFn = { layerType: layerTypes.REFERENCELINE, accessors: [], }), + [layerTypes.ANNOTATIONS]: ({ layerId }: { layerId: string }): XYAnnotationLayerConfig => ({ + layerId, + layerType: layerTypes.ANNOTATIONS, + annotations: [], + }), }; export function newLayerState({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 8aa2aaf16ae5f..b448ebfbd455e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State } from '../types'; import { FormatFactory } from '../../../common'; @@ -20,7 +21,7 @@ import { } from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; import { TooltipWrapper } from '../../shared_components'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer, getDataLayers } from '../visualization_helpers'; const tooltipContent = { auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { @@ -62,15 +63,17 @@ export const ColorPicker = ({ if (overwriteColor || !frame.activeData) return overwriteColor; if (isReferenceLayer(layer)) { return defaultReferenceLineColor; + } else if (isAnnotationsLayer(layer)) { + return defaultAnnotationColor; } const sortedAccessors: string[] = getSortedAccessors( - frame.datasourceLayers[layer.layerId], + frame.datasourceLayers[layer.layerId] ?? layer.accessors, layer ); const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, formatFactory ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx index 465a627fa33b2..c4e5268cfb8af 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -16,8 +16,9 @@ import { trackUiEvent } from '../../lens_ui_telemetry'; import { StaticHeader } from '../../shared_components'; import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; import { LensIconChartBarReferenceLine } from '../../assets/chart_bar_reference_line'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; import { updateLayer } from '.'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers'; export function LayerHeader(props: VisualizationLayerWidgetProps) { const layer = props.state.layers.find((l) => l.layerId === props.layerId); @@ -26,6 +27,8 @@ export function LayerHeader(props: VisualizationLayerWidgetProps) { } if (isReferenceLayer(layer)) { return ; + } else if (isAnnotationsLayer(layer)) { + return ; } return ; } @@ -41,6 +44,17 @@ function ReferenceLayerHeader() { ); } +function AnnotationsLayerHeader() { + return ( + + ); +} + function DataLayerHeader(props: VisualizationLayerWidgetProps) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); const { state, layerId } = props; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index f00d60b0dc814..78020034c3d43 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -70,6 +70,7 @@ export const ReferenceLinePanel = ( return ( <> + {' '} ; + +export const euiIconsSet = [ { value: 'empty', label: i18n.translate('xpack.lens.xyChart.iconSelect.noIconLabel', { @@ -70,29 +72,35 @@ const icons = [ }, ]; -const IconView = (props: { value?: string; label: string }) => { +const IconView = (props: { value?: string; label: string; icon?: IconType }) => { if (!props.value) return null; return ( - - - {` ${props.label}`} - + + + + + {props.label} + ); }; export const IconSelect = ({ value, onChange, + customIconSet = euiIconsSet, }: { value?: string; onChange: (newIcon: string) => void; + customIconSet?: IconSet; }) => { - const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + const selectedIcon = + customIconSet.find((option) => value === option.value) || + customIconSet.find((option) => option.value === 'empty')!; return ( { onChange(selection[0].value!); @@ -100,7 +108,11 @@ export const IconSelect = ({ singleSelection={{ asPlainText: true }} renderOption={IconView} compressed - prepend={hasIcon(selectedIcon.value) ? : undefined} + prepend={ + hasIcon(selectedIcon.value) ? ( + + ) : undefined + } /> ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx index db01a027d8fec..766d5462db787 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx @@ -40,8 +40,8 @@ export const LineStyleSettings = ({ defaultMessage: 'Line', })} > - - + + { @@ -49,9 +49,8 @@ export const LineStyleSettings = ({ }} /> - + void; isHorizontal: boolean; + customIconSet?: IconSet; }) => { return ( <> @@ -133,13 +136,15 @@ export const MarkerDecorationSettings = ({ })} > { setConfig({ icon: newIcon }); }} /> - {hasIcon(currentConfig?.icon) || currentConfig?.textVisibility ? ( + {currentConfig?.iconPosition && + (hasIcon(currentConfig?.icon) || currentConfig?.textVisibility) ? ( { @@ -533,6 +535,60 @@ describe('xy_suggestions', () => { ); }); + test('passes annotation layer without modifying it', () => { + const annotationLayer: XYAnnotationLayerConfig = { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + { + id: '1', + key: { + type: 'point_in_time', + timestamp: '2020-20-22', + }, + label: 'annotation', + }, + ], + }; + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + fittingFunction: 'None', + layers: [ + { + accessors: ['price'], + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'bar', + splitAccessor: 'date', + xAccessor: 'product', + }, + annotationLayer, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + keptLayerIds: [], + }); + + suggestions.every((suggestion) => + expect(suggestion.state.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerType: layerTypes.ANNOTATIONS, + }), + ]) + ) + ); + }); + test('includes passed in palette for split charts if specified', () => { const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; const [suggestion] = getSuggestions({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 1578442b52815..bd5a37c206c6c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -521,7 +521,10 @@ function buildSuggestion({ const keptLayers = currentState ? currentState.layers // Remove layers that aren't being suggested - .filter((layer) => keptLayerIds.includes(layer.layerId)) + .filter( + (layer) => + keptLayerIds.includes(layer.layerId) || layer.layerType === layerTypes.ANNOTATIONS + ) // Update in place .map((layer) => (layer.layerId === layerId ? newLayer : layer)) // Replace the seriesType on all previous layers diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index 84e238b3eb15e..c68fed23a7fdb 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -12,6 +12,7 @@ import { yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, @@ -40,6 +41,7 @@ export const setupExpressions = ( yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 583e2963a1ca7..76e25f8b08639 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -1,4 +1,3 @@ - { "extends": "../../../tsconfig.base.json", "compilerOptions": { @@ -15,31 +14,86 @@ "../../../typings/**/*" ], "references": [ - { "path": "../spaces/tsconfig.json" }, - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../task_manager/tsconfig.json" }, - { "path": "../global_search/tsconfig.json"}, - { "path": "../saved_objects_tagging/tsconfig.json"}, - { "path": "../../../src/plugins/data/tsconfig.json"}, - { "path": "../../../src/plugins/data_views/tsconfig.json"}, - { "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"}, - { "path": "../../../src/plugins/charts/tsconfig.json"}, - { "path": "../../../src/plugins/expressions/tsconfig.json"}, - { "path": "../../../src/plugins/navigation/tsconfig.json" }, - { "path": "../../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../../../src/plugins/visualizations/tsconfig.json" }, - { "path": "../../../src/plugins/dashboard/tsconfig.json" }, - { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json" }, - { "path": "../../../src/plugins/share/tsconfig.json" }, - { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../../../src/plugins/saved_objects/tsconfig.json"}, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json"}, - { "path": "../../../src/plugins/presentation_util/tsconfig.json"}, - { "path": "../../../src/plugins/field_formats/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"} + { + "path": "../spaces/tsconfig.json" + }, + { + "path": "../../../src/core/tsconfig.json" + }, + { + "path": "../task_manager/tsconfig.json" + }, + { + "path": "../global_search/tsconfig.json" + }, + { + "path": "../saved_objects_tagging/tsconfig.json" + }, + { + "path": "../../../src/plugins/data/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_views/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_view_field_editor/tsconfig.json" + }, + { + "path": "../../../src/plugins/charts/tsconfig.json" + }, + { + "path": "../../../src/plugins/expressions/tsconfig.json" + }, + { + "path": "../../../src/plugins/navigation/tsconfig.json" + }, + { + "path": "../../../src/plugins/url_forwarding/tsconfig.json" + }, + { + "path": "../../../src/plugins/visualizations/tsconfig.json" + }, + { + "path": "../../../src/plugins/dashboard/tsconfig.json" + }, + { + "path": "../../../src/plugins/ui_actions/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/share/tsconfig.json" + }, + { + "path": "../../../src/plugins/usage_collection/tsconfig.json" + }, + { + "path": "../../../src/plugins/saved_objects/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_utils/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_react/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/presentation_util/tsconfig.json" + }, + { + "path": "../../../src/plugins/field_formats/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json" + }, + { + "path": "../../../src/plugins/event_annotation/tsconfig.json" + } ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index db10095ce0591..5fbde8959b364 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -530,7 +530,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à", "xpack.lens.indexPattern.records": "Enregistrements", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction", - "xpack.lens.indexPattern.removeColumnAriaLabel": "Ajouter ou glisser-déposer un champ dans {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "Retirer la configuration de \"{groupLabel}\"", "xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ du modèle d'indexation", "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2395df6d2d901..9d1ec062fe1b3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -611,7 +611,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name}の希少な値", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを追加するか、{groupLabel}までドラッグアンドドロップします", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "データビューフィールドを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6d4465ae16487..b055d663f9e69 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -617,7 +617,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name} 的稀有值", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "将字段添加或拖放到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除数据视图字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查数据视图或选取其他字段。", From 0b4282e1f5be29f44eab61340d947acaec2326b3 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Wed, 23 Mar 2022 14:20:26 -0700 Subject: [PATCH 32/66] Fix for process event pagination in session view (#128421) Co-authored-by: mitodrummer --- .../session_view/public/components/session_view/hooks.ts | 4 ++-- .../session_view/server/routes/process_events_route.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index bf8796336602d..e48b3a335dbd3 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -58,7 +58,7 @@ export const useFetchSessionViewProcessEvents = ( getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + cursor: lastPage.events[lastPage.events.length - 1].process.start, forward: true, }; } @@ -66,7 +66,7 @@ export const useFetchSessionViewProcessEvents = ( getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: firstPage.events[0]['@timestamp'], + cursor: firstPage.events[0].process.start, forward: false, }; } diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 7be1885c70ab1..0dc864c51a07d 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -57,7 +57,7 @@ export const doSearch = async ( { 'process.start': forward ? 'asc' : 'desc' }, { '@timestamp': forward ? 'asc' : 'desc' }, ], - search_after: cursor ? [cursor] : undefined, + search_after: cursor ? [cursor, cursor] : undefined, }, }); From 82d4cd56dc6ba7fc0d43bb1c046f225f7c39636b Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 23 Mar 2022 18:28:39 -0400 Subject: [PATCH 33/66] [Dashboard][Controls] Add Control Group Search Settings (#128090) * Added ability to toggle hierarchical chaining, control validation, and query bar sync to the Control Group --- .../control_group/control_group_constants.ts | 26 + .../controls/common/control_group/types.ts | 3 + src/plugins/controls/common/index.ts | 1 + .../public/__stories__/controls.stories.tsx | 1 + .../control_group/control_group_strings.ts | 79 ++- .../editor/control_group_editor.tsx | 337 ++++++++--- .../control_group/editor/create_control.tsx | 2 +- .../editor/edit_control_group.tsx | 46 +- .../control_group/editor/editor_constants.ts | 8 +- .../control_group_chaining_system.ts | 80 +++ .../embeddable/control_group_container.tsx | 75 +-- .../control_group_container_factory.ts | 10 +- .../options_list/options_list_embeddable.tsx | 8 +- .../dashboard_container_persistable_state.ts | 7 +- .../embeddable/dashboard_control_group.ts | 61 +- .../common/saved_dashboard_references.ts | 2 - src/plugins/dashboard/common/types.ts | 20 +- .../dashboard_container_factory.tsx | 3 +- .../lib/dashboard_control_group.ts | 38 +- .../state/dashboard_state_slice.ts | 4 +- src/plugins/dashboard/public/types.ts | 11 +- .../server/saved_objects/dashboard.ts | 2 + src/plugins/embeddable/public/index.ts | 1 + .../embeddable/public/lib/containers/index.ts | 8 +- .../dashboard_controls_integration.ts | 566 ------------------ test/functional/apps/dashboard/index.ts | 1 - .../controls/control_group_chaining.ts | 146 +++++ .../controls/control_group_settings.ts | 103 ++++ .../controls/controls_callout.ts | 63 ++ .../apps/dashboard_elements/controls/index.ts | 54 ++ .../controls/options_list.ts | 369 ++++++++++++ .../apps/dashboard_elements/index.ts | 1 + .../page_objects/dashboard_page_controls.ts | 87 +++ .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 35 files changed, 1397 insertions(+), 834 deletions(-) create mode 100644 src/plugins/controls/common/control_group/control_group_constants.ts create mode 100644 src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts delete mode 100644 test/functional/apps/dashboard/dashboard_controls_integration.ts create mode 100644 test/functional/apps/dashboard_elements/controls/control_group_chaining.ts create mode 100644 test/functional/apps/dashboard_elements/controls/control_group_settings.ts create mode 100644 test/functional/apps/dashboard_elements/controls/controls_callout.ts create mode 100644 test/functional/apps/dashboard_elements/controls/index.ts create mode 100644 test/functional/apps/dashboard_elements/controls/options_list.ts diff --git a/src/plugins/controls/common/control_group/control_group_constants.ts b/src/plugins/controls/common/control_group/control_group_constants.ts new file mode 100644 index 0000000000000..467394614e12c --- /dev/null +++ b/src/plugins/controls/common/control_group/control_group_constants.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlGroupInput } from '..'; +import { ControlStyle, ControlWidth } from '../types'; + +export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; +export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; + +export const getDefaultControlGroupInput = (): Omit => ({ + panels: {}, + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + controlStyle: DEFAULT_CONTROL_STYLE, + chainingSystem: 'HIERARCHICAL', + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, +}); diff --git a/src/plugins/controls/common/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts index 4e1bddc08143f..988109d237cdc 100644 --- a/src/plugins/controls/common/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -17,11 +17,14 @@ export interface ControlPanelState i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { - defaultMessage: 'Title', + defaultMessage: 'Label', }), getControlTypeTitle: () => i18n.translate('controls.controlGroup.manageControl.controlTypesTitle', { @@ -82,10 +82,6 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.management.defaultWidthTitle', { defaultMessage: 'Default size', }), - getLayoutTitle: () => - i18n.translate('controls.controlGroup.management.layoutTitle', { - defaultMessage: 'Layout', - }), getDeleteButtonTitle: () => i18n.translate('controls.controlGroup.management.delete', { defaultMessage: 'Delete control', @@ -120,18 +116,22 @@ export const ControlGroupStrings = { defaultMessage: 'Large', }), }, - controlStyle: { - getDesignSwitchLegend: () => - i18n.translate('controls.controlGroup.management.layout.designSwitchLegend', { - defaultMessage: 'Switch control designs', + labelPosition: { + getLabelPositionTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.title', { + defaultMessage: 'Label position', + }), + getLabelPositionLegend: () => + i18n.translate('controls.controlGroup.management.labelPosition.designSwitchLegend', { + defaultMessage: 'Switch label position between inline and above', }), - getSingleLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line', + getInlineTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.inline', { + defaultMessage: 'Inline', }), - getTwoLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Double line', + getAboveTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.above', { + defaultMessage: 'Above', }), }, deleteControls: { @@ -192,6 +192,55 @@ export const ControlGroupStrings = { defaultMessage: 'Cancel', }), }, + validateSelections: { + getValidateSelectionsTitle: () => + i18n.translate('controls.controlGroup.management.validate.title', { + defaultMessage: 'Validate user selections', + }), + getValidateSelectionsSubTitle: () => + i18n.translate('controls.controlGroup.management.validate.subtitle', { + defaultMessage: + 'Automatically ignore any control selection that would result in no data.', + }), + }, + controlChaining: { + getHierarchyTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.title', { + defaultMessage: 'Chain controls', + }), + getHierarchySubTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.subtitle', { + defaultMessage: + 'Selections in one control narrow down available options in the next. Controls are chained from left to right.', + }), + }, + querySync: { + getQuerySettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.searchSettingsTitle', { + defaultMessage: 'Sync with query bar', + }), + getQuerySettingsSubtitle: () => + i18n.translate('controls.controlGroup.management.query.useAllSearchSettingsTitle', { + defaultMessage: + 'Keeps the control group in sync with the query bar by applying time range, filter pills, and queries from the query bar', + }), + getAdvancedSettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.advancedSettings', { + defaultMessage: 'Advanced', + }), + getIgnoreTimerangeTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreTimerange', { + defaultMessage: 'Ignore timerange', + }), + getIgnoreQueryTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreQuery', { + defaultMessage: 'Ignore query bar', + }), + getIgnoreFilterPillsTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreFilterPills', { + defaultMessage: 'Ignore filter pills', + }), + }, }, floatingActions: { getEditButtonTitle: () => diff --git a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx index 82eb4f4c2eb09..95e2066541b5f 100644 --- a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { omit } from 'lodash'; +import fastIsEqual from 'fast-deep-equal'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyoutHeader, EuiButtonGroup, @@ -28,38 +30,101 @@ import { EuiButtonEmpty, EuiSpacer, EuiCheckbox, + EuiForm, + EuiAccordion, + useGeneratedHtmlId, + EuiSwitch, + EuiText, + EuiHorizontalRule, } from '@elastic/eui'; +import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlStyle, ControlWidth } from '../../types'; -import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; +import { ParentIgnoreSettings } from '../..'; +import { ControlsPanels } from '../types'; +import { ControlGroupInput } from '..'; +import { + DEFAULT_CONTROL_WIDTH, + getDefaultControlGroupInput, +} from '../../../common/control_group/control_group_constants'; interface EditControlGroupProps { - width: ControlWidth; - controlStyle: ControlStyle; - setAllWidths: boolean; + initialInput: ControlGroupInput; controlCount: number; - updateControlStyle: (controlStyle: ControlStyle) => void; - updateWidth: (newWidth: ControlWidth) => void; - updateAllControlWidths: (newWidth: ControlWidth) => void; - onCancel: () => void; + updateInput: (input: Partial) => void; + onDeleteAll: () => void; onClose: () => void; } +type EditorControlGroupInput = ControlGroupInput & + Required>; + +const editorControlGroupInputIsEqual = (a: ControlGroupInput, b: ControlGroupInput) => + fastIsEqual(a, b); + export const ControlGroupEditor = ({ - width, - controlStyle, - setAllWidths, controlCount, - updateControlStyle, - updateWidth, - updateAllControlWidths, - onCancel, + initialInput, + updateInput, + onDeleteAll, onClose, }: EditControlGroupProps) => { - const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle); - const [currentWidth, setCurrentWidth] = useState(width); - const [applyToAll, setApplyToAll] = useState(setAllWidths); + const [resetAllWidths, setResetAllWidths] = useState(false); + const advancedSettingsAccordionId = useGeneratedHtmlId({ prefix: 'advancedSettingsAccordion' }); + + const [controlGroupEditorState, setControlGroupEditorState] = useState({ + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + ...getDefaultControlGroupInput(), + ...initialInput, + }); + + const updateControlGroupEditorSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ...newSettings, + }); + }, + [controlGroupEditorState] + ); + + const updateIgnoreSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ignoreParentSettings: { + ...(controlGroupEditorState.ignoreParentSettings ?? {}), + ...newSettings, + }, + }); + }, + [controlGroupEditorState] + ); + + const fullQuerySyncActive = useMemo( + () => + !Object.values(omit(controlGroupEditorState.ignoreParentSettings, 'ignoreValidations')).some( + Boolean + ), + [controlGroupEditorState] + ); + + const applyChangesToInput = useCallback(() => { + const inputToApply = { ...controlGroupEditorState }; + if (resetAllWidths) { + const newPanels = {} as ControlsPanels; + Object.entries(initialInput.panels).forEach( + ([id, panel]) => + (newPanels[id] = { + ...panel, + width: inputToApply.defaultControlWidth, + }) + ); + inputToApply.panels = newPanels; + } + if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) updateInput(inputToApply); + }, [controlGroupEditorState, resetAllWidths, initialInput, updateInput]); return ( <> @@ -69,57 +134,183 @@ export const ControlGroupEditor = ({ - - { - setCurrentControlStyle(newControlStyle as ControlStyle); - }} - /> - - - - { - setCurrentWidth(newWidth as ControlWidth); - }} - /> - - {controlCount > 0 ? ( - <> - - { - setApplyToAll(e.target.checked); + + + { + // The UI copy calls this setting labelPosition, but to avoid an unnecessary migration it will be left as controlStyle in the state. + updateControlGroupEditorSetting({ controlStyle: newControlStyle as ControlStyle }); }} /> - - - {ControlGroupStrings.management.getDeleteAllButtonTitle()} - - - ) : null} + + + + <> + { + updateControlGroupEditorSetting({ + defaultControlWidth: newWidth as ControlWidth, + }); + }} + /> + {controlCount > 0 && ( + <> + + { + setResetAllWidths(e.target.checked); + }} + /> + + )} + + + + + + + { + const newSetting = !e.target.checked; + updateIgnoreSetting({ + ignoreFilters: newSetting, + ignoreTimerange: newSetting, + ignoreQuery: newSetting, + }); + }} + /> + + + +

    {ControlGroupStrings.management.querySync.getQuerySettingsTitle()}

    +
    + +

    {ControlGroupStrings.management.querySync.getQuerySettingsSubtitle()}

    +
    + + + + + updateIgnoreSetting({ ignoreTimerange: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreQuery: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreFilters: e.target.checked })} + /> + + +
    +
    + + + + + updateIgnoreSetting({ ignoreValidations: !e.target.checked })} + /> + + + +

    + {ControlGroupStrings.management.validateSelections.getValidateSelectionsTitle()} +

    +
    + +

    + {ControlGroupStrings.management.validateSelections.getValidateSelectionsSubTitle()} +

    +
    +
    +
    + + + + + + updateControlGroupEditorSetting({ + chainingSystem: e.target.checked ? 'HIERARCHICAL' : 'NONE', + }) + } + /> + + + +

    {ControlGroupStrings.management.controlChaining.getHierarchyTitle()}

    +
    + +

    {ControlGroupStrings.management.controlChaining.getHierarchySubTitle()}

    +
    +
    +
    + {controlCount > 0 && ( + <> + + + + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + )} +
    @@ -141,15 +332,7 @@ export const ControlGroupEditor = ({ color="primary" data-test-subj="control-group-editor-save" onClick={() => { - if (currentControlStyle && currentControlStyle !== controlStyle) { - updateControlStyle(currentControlStyle); - } - if (currentWidth && currentWidth !== width) { - updateWidth(currentWidth); - } - if (applyToAll) { - updateAllControlWidths(currentWidth); - } + applyChangesToInput(); onClose(); }} > diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 218024433802b..005341359a8a9 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -12,10 +12,10 @@ import React from 'react'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { OverlayRef } from '../../../../../core/public'; -import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; import { toMountPoint } from '../../../../kibana_react/public'; +import { DEFAULT_CONTROL_WIDTH } from '../../../common/control_group/control_group_constants'; export type CreateControlButtonTypes = 'toolbar' | 'callout'; export interface CreateControlButtonProps { diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 5595e5be24b04..f21d5d550f1a3 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -9,34 +9,20 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_STYLE } from './editor_constants'; -import { ControlsPanels } from '../types'; -import { pluginServices } from '../../services'; -import { ControlStyle, ControlWidth } from '../../types'; -import { ControlGroupStrings } from '../control_group_strings'; import { toMountPoint } from '../../../../kibana_react/public'; -import { OverlayRef } from '../../../../../core/public'; +import { ControlGroupStrings } from '../control_group_strings'; import { ControlGroupEditor } from './control_group_editor'; +import { OverlayRef } from '../../../../../core/public'; +import { pluginServices } from '../../services'; +import { ControlGroupContainer } from '..'; export interface EditControlGroupButtonProps { - controlStyle: ControlStyle; - panels?: ControlsPanels; - defaultControlWidth?: ControlWidth; - setControlStyle: (setControlStyle: ControlStyle) => void; - setDefaultControlWidth: (defaultControlWidth: ControlWidth) => void; - setAllControlWidths: (defaultControlWidth: ControlWidth) => void; - removeEmbeddable?: (panelId: string) => void; + controlGroupContainer: ControlGroupContainer; closePopover: () => void; } export const EditControlGroup = ({ - panels, - defaultControlWidth, - controlStyle, - setControlStyle, - setDefaultControlWidth, - setAllControlWidths, - removeEmbeddable, + controlGroupContainer, closePopover, }: EditControlGroupButtonProps) => { const { overlays } = pluginServices.getServices(); @@ -45,15 +31,17 @@ export const EditControlGroup = ({ const editControlGroup = () => { const PresentationUtilProvider = pluginServices.getContextProvider(); - const onCancel = (ref: OverlayRef) => { - if (!removeEmbeddable || !panels) return; + const onDeleteAll = (ref: OverlayRef) => { openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), buttonColor: 'danger', }).then((confirmed) => { - if (confirmed) Object.keys(panels).forEach((panelId) => removeEmbeddable(panelId)); + if (confirmed) + Object.keys(controlGroupContainer.getInput().panels).forEach((panelId) => + controlGroupContainer.removeEmbeddable(panelId) + ); ref.close(); }); }; @@ -62,14 +50,10 @@ export const EditControlGroup = ({ toMountPoint( onCancel(flyoutInstance)} + initialInput={controlGroupContainer.getInput()} + updateInput={(changes) => controlGroupContainer.updateInput(changes)} + controlCount={Object.keys(controlGroupContainer.getInput().panels ?? {}).length} + onDeleteAll={() => onDeleteAll(flyoutInstance)} onClose={() => flyoutInstance.close()} /> diff --git a/src/plugins/controls/public/control_group/editor/editor_constants.ts b/src/plugins/controls/public/control_group/editor/editor_constants.ts index 4c3c4c1af7938..5acad90cfbf8f 100644 --- a/src/plugins/controls/public/control_group/editor/editor_constants.ts +++ b/src/plugins/controls/public/control_group/editor/editor_constants.ts @@ -6,12 +6,8 @@ * Side Public License, v 1. */ -import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; -export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; -export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; - export const CONTROL_WIDTH_OPTIONS = [ { id: `auto`, @@ -39,11 +35,11 @@ export const CONTROL_LAYOUT_OPTIONS = [ { id: `oneLine`, 'data-test-subj': 'control-editor-layout-oneLine', - label: ControlGroupStrings.management.controlStyle.getSingleLineTitle(), + label: ControlGroupStrings.management.labelPosition.getInlineTitle(), }, { id: `twoLine`, 'data-test-subj': 'control-editor-layout-twoLine', - label: ControlGroupStrings.management.controlStyle.getTwoLineTitle(), + label: ControlGroupStrings.management.labelPosition.getAboveTitle(), }, ]; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts new file mode 100644 index 0000000000000..f0acf9ca811e8 --- /dev/null +++ b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Filter } from '@kbn/es-query'; + +import { Subject } from 'rxjs'; +import { ControlEmbeddable } from '../../types'; +import { ChildEmbeddableOrderCache } from './control_group_container'; +import { EmbeddableContainerSettings, isErrorEmbeddable } from '../../../../embeddable/public'; +import { ControlGroupChainingSystem, ControlGroupInput } from '../../../common/control_group/types'; + +interface GetPrecedingFiltersProps { + id: string; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface OnChildChangedProps { + childOutputChangedId: string; + recalculateFilters$: Subject; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface ChainingSystem { + getContainerSettings: ( + initialInput: ControlGroupInput + ) => EmbeddableContainerSettings | undefined; + getPrecedingFilters: (props: GetPrecedingFiltersProps) => Filter[] | undefined; + onChildChange: (props: OnChildChangedProps) => void; +} + +export const ControlGroupChainingSystems: { + [key in ControlGroupChainingSystem]: ChainingSystem; +} = { + HIERARCHICAL: { + getContainerSettings: (initialInput) => ({ + childIdInitializeOrder: Object.values(initialInput.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel) => panel.explicitInput.id), + initializeSequentially: true, + }), + getPrecedingFilters: ({ id, childOrder, getChild }) => { + let filters: Filter[] = []; + const order = childOrder.IdsToOrder?.[id]; + if (!order || order === 0) return filters; + for (let i = 0; i < order; i++) { + const embeddable = getChild(childOrder.idsInOrder[i]); + if (!embeddable || isErrorEmbeddable(embeddable)) return filters; + filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; + } + return filters; + }, + onChildChange: ({ childOutputChangedId, childOrder, recalculateFilters$, getChild }) => { + if (childOutputChangedId === childOrder.lastChildId) { + // the last control's output has updated, recalculate filters + recalculateFilters$.next(); + return; + } + + // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent + const nextOrder = childOrder.IdsToOrder[childOutputChangedId] + 1; + if (nextOrder >= childOrder.idsInOrder.length) return; + setTimeout( + () => getChild(childOrder.idsInOrder[nextOrder]).refreshInputFromParent(), + 1 // run on next tick + ); + }, + }, + NONE: { + getContainerSettings: () => undefined, + getPrecedingFilters: () => undefined, + onChildChange: ({ recalculateFilters$ }) => recalculateFilters$.next(), + }, +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 4bae605e0ef49..e73aff832ab1e 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -40,18 +40,18 @@ import { pluginServices } from '../../services'; import { DataView } from '../../../../data_views/public'; import { ControlGroupStrings } from '../control_group_strings'; import { EditControlGroup } from '../editor/edit_control_group'; -import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { Container, EmbeddableFactory } from '../../../../embeddable/public'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; +import { ControlGroupChainingSystems } from './control_group_chaining_system'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; -import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); -interface ChildEmbeddableOrderCache { +export interface ChildEmbeddableOrderCache { IdsToOrder: { [key: string]: number }; idsInOrder: string[]; lastChildId: string; @@ -104,22 +104,7 @@ export class ControlGroupContainer extends Container< }; private getEditControlGroupButton = (closePopover: () => void) => { - return ( - this.updateInput({ controlStyle })} - setDefaultControlWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })} - setAllControlWidths={(defaultControlWidth) => { - Object.keys(this.getInput().panels).forEach( - (panelId) => (this.getInput().panels[panelId].width = defaultControlWidth) - ); - }} - removeEmbeddable={(id) => this.removeEmbeddable(id)} - closePopover={closePopover} - /> - ); + return ; }; /** @@ -154,12 +139,7 @@ export class ControlGroupContainer extends Container< { embeddableLoaded: {} }, pluginServices.getServices().controls.getControlFactory, parent, - { - childIdInitializeOrder: Object.values(initialInput.panels) - .sort((a, b) => (a.order > b.order ? 1 : -1)) - .map((panel) => panel.explicitInput.id), - initializeSequentially: true, - } + ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput) ); this.recalculateFilters$ = new Subject(); @@ -226,20 +206,12 @@ export class ControlGroupContainer extends Container< .pipe(anyChildChangePipe) .subscribe((childOutputChangedId) => { this.recalculateDataViews(); - if (childOutputChangedId === this.childOrderCache.lastChildId) { - // the last control's output has updated, recalculate filters - this.recalculateFilters$.next(); - return; - } - - // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent - const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1; - if (nextOrder >= Object.keys(this.children).length) return; - setTimeout( - () => - this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(), - 1 // run on next tick - ); + ControlGroupChainingSystems[this.getInput().chainingSystem].onChildChange({ + childOutputChangedId, + childOrder: this.childOrderCache, + getChild: (id) => this.getChild(id), + recalculateFilters$: this.recalculateFilters$, + }); }) ); @@ -251,18 +223,6 @@ export class ControlGroupContainer extends Container< ); }; - private getPrecedingFilters = (id: string) => { - let filters: Filter[] = []; - const order = this.childOrderCache.IdsToOrder?.[id]; - if (!order || order === 0) return filters; - for (let i = 0; i < order; i++) { - const embeddable = this.getChild(this.childOrderCache.idsInOrder[i]); - if (!embeddable || isErrorEmbeddable(embeddable)) return filters; - filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; - } - return filters; - }; - private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => { const panels = this.getInput().panels; const IdsToOrder: { [key: string]: number } = {}; @@ -314,20 +274,25 @@ export class ControlGroupContainer extends Container< } return { order: nextOrder, - width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, + width: this.getInput().defaultControlWidth, ...panelState, } as ControlPanelState; } protected getInheritedInput(id: string): ControlInput { - const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); + const { filters, query, ignoreParentSettings, timeRange, chainingSystem } = this.getInput(); - const precedingFilters = this.getPrecedingFilters(id); + const precedingFilters = ControlGroupChainingSystems[chainingSystem].getPrecedingFilters({ + id, + childOrder: this.childOrderCache, + getChild: (getChildId: string) => this.getChild(getChildId), + }); const allFilters = [ ...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []), - ...precedingFilters, + ...(precedingFilters ?? []), ]; return { + ignoreParentSettings, filters: allFilters, query: ignoreParentSettings?.ignoreQuery ? undefined : query, timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index d2e057a613070..11bf0bbc4aa7f 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -23,6 +23,7 @@ import { createControlGroupExtract, createControlGroupInject, } from '../../../common/control_group/control_group_persistable_state'; +import { getDefaultControlGroupInput } from '../../../common/control_group/control_group_constants'; export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition { public readonly isContainerType = true; @@ -42,14 +43,7 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition }; public getDefaultInput(): Partial { - return { - panels: {}, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - }, - }; + return getDefaultControlGroupInput(); } public create = async (initialInput: ControlGroupInput, parent?: Container) => { diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index 2575d5724535f..0f5a7524db02b 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -56,6 +56,7 @@ interface OptionsListDataFetchProps { search?: string; fieldName: string; dataViewId: string; + validate?: boolean; query?: ControlInput['query']; filters?: ControlInput['filters']; } @@ -115,6 +116,7 @@ export class OptionsListEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -218,12 +220,12 @@ export class OptionsListEmbeddable extends Embeddable `${state.explicitInput.id}:`; const controlGroupReferencePrefix = 'controlGroup_'; +const controlGroupId = 'dashboard_control_group'; export const createInject = ( persistableStateService: EmbeddablePersistableStateService @@ -89,11 +90,12 @@ export const createInject = ( { ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }, controlGroupReferences ); workingState.controlGroupInput = - injectedControlGroupState as DashboardContainerControlGroupInput; + injectedControlGroupState as unknown as DashboardContainerControlGroupInput; } return workingState as EmbeddableStateWithType; @@ -155,9 +157,10 @@ export const createExtract = ( persistableStateService.extract({ ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }); workingState.controlGroupInput = - extractedControlGroupState as DashboardContainerControlGroupInput; + extractedControlGroupState as unknown as DashboardContainerControlGroupInput; const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({ ...reference, name: `${controlGroupReferencePrefix}${reference.name}`, diff --git a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts index 95cb6c38ee9d7..ce6a1f358661e 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts @@ -7,57 +7,68 @@ */ import { SerializableRecord } from '@kbn/utility-types'; -import { ControlGroupInput } from '../../../controls/common'; -import { ControlStyle } from '../../../controls/common/types'; +import { ControlGroupInput, getDefaultControlGroupInput } from '../../../controls/common'; import { RawControlGroupAttributes } from '../types'; +export const getDefaultDashboardControlGroupInput = getDefaultControlGroupInput; + export const controlGroupInputToRawAttributes = ( controlGroupInput: Omit -): Omit => { +): RawControlGroupAttributes => { return { controlStyle: controlGroupInput.controlStyle, + chainingSystem: controlGroupInput.chainingSystem, panelsJSON: JSON.stringify(controlGroupInput.panels), + ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings), }; }; -export const getDefaultDashboardControlGroupInput = () => ({ - controlStyle: 'oneLine' as ControlGroupInput['controlStyle'], - panels: {}, -}); +const safeJSONParse = (jsonString?: string): OutType | undefined => { + if (!jsonString && typeof jsonString !== 'string') return; + try { + return JSON.parse(jsonString) as OutType; + } catch { + return; + } +}; export const rawAttributesToControlGroupInput = ( - rawControlGroupAttributes: Omit + rawControlGroupAttributes: RawControlGroupAttributes ): Omit | undefined => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); + const { chainingSystem, controlStyle, ignoreParentSettingsJSON, panelsJSON } = + rawControlGroupAttributes; + const panels = safeJSONParse(panelsJSON); + const ignoreParentSettings = + safeJSONParse(ignoreParentSettingsJSON); return { - controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? JSON.parse(rawControlGroupAttributes?.panelsJSON) - : defaultControlGroupInput.panels, + ...defaultControlGroupInput, + ...(chainingSystem ? { chainingSystem } : {}), + ...(controlStyle ? { controlStyle } : {}), + ...(ignoreParentSettings ? { ignoreParentSettings } : {}), + ...(panels ? { panels } : {}), }; }; export const rawAttributesToSerializable = ( rawControlGroupAttributes: Omit ): SerializableRecord => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); return { + chainingSystem: rawControlGroupAttributes?.chainingSystem, controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord) - : defaultControlGroupInput.panels, + ignoreParentSettings: safeJSONParse(rawControlGroupAttributes?.ignoreParentSettingsJSON) ?? {}, + panels: safeJSONParse(rawControlGroupAttributes?.panelsJSON) ?? {}, }; }; export const serializableToRawAttributes = ( - controlGroupInput: SerializableRecord -): Omit => { + serializable: SerializableRecord +): Omit => { return { - controlStyle: controlGroupInput.controlStyle as ControlStyle, - panelsJSON: JSON.stringify(controlGroupInput.panels), + controlStyle: serializable.controlStyle as RawControlGroupAttributes['controlStyle'], + chainingSystem: serializable.chainingSystem as RawControlGroupAttributes['chainingSystem'], + ignoreParentSettingsJSON: JSON.stringify(serializable.ignoreParentSettings), + panelsJSON: JSON.stringify(serializable.panels), }; }; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index 346190e4fef91..fe549a4c13a1e 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -19,7 +19,6 @@ import { convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; -import { CONTROL_GROUP_TYPE } from '../../controls/common'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; @@ -51,7 +50,6 @@ function dashboardAttributesToState(attributes: SavedObjectAttributes): { if (controlGroupPanels && typeof controlGroupPanels === 'object') { controlGroupInput = { ...rawControlGroupInput, - type: CONTROL_GROUP_TYPE, panels: controlGroupPanels, }; } diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 29e3d48d7f0d5..49caa41251211 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -98,17 +98,19 @@ export type SavedDashboardPanel730ToLatest = Pick< // Making this interface because so much of the Container type from embeddable is tied up in public // Once that is all available from common, we should be able to move the dashboard_container type to our common as well -export interface DashboardContainerControlGroupInput extends EmbeddableStateWithType { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; - id: string; -} +// dashboard only persists part of the Control Group Input +export type DashboardContainerControlGroupInput = Pick< + ControlGroupInput, + 'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings' +>; -export interface RawControlGroupAttributes { - controlStyle: ControlGroupInput['controlStyle']; +export type RawControlGroupAttributes = Omit< + DashboardContainerControlGroupInput, + 'panels' | 'ignoreParentSettings' +> & { + ignoreParentSettingsJSON: string; panelsJSON: string; - id: string; -} +}; export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 2595824e8b02e..564080831607c 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { identity, pickBy } from 'lodash'; import { DashboardContainerInput } from '../..'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import type { DashboardContainer, DashboardContainerServices } from './dashboard_container'; @@ -90,7 +91,7 @@ export class DashboardContainerFactoryDefinition const controlGroup = await controlsGroupFactory?.create({ id: `control_group_${id ?? 'new_dashboard'}`, ...getDefaultDashboardControlGroupInput(), - ...(controlGroupInput ?? {}), + ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults timeRange, viewMode, filters, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts index e421ec3477354..ba60af8d02aea 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -11,7 +11,8 @@ import deepEqual from 'fast-deep-equal'; import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query'; import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators'; -import { DashboardContainer } from '..'; +import { pick } from 'lodash'; +import { DashboardContainer, DashboardContainerControlGroupInput } from '..'; import { DashboardState } from '../../types'; import { DashboardContainerInput, DashboardSavedObject } from '../..'; import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public'; @@ -20,13 +21,6 @@ import { getDefaultDashboardControlGroupInput, rawAttributesToControlGroupInput, } from '../../../common'; - -// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. -export interface DashboardControlGroupInput { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; -} - interface DiffChecks { [key: string]: (a?: unknown, b?: unknown) => boolean; } @@ -60,6 +54,8 @@ export const syncDashboardControlGroup = async ({ const controlGroupDiff: DiffChecks = { panels: deepEqual, controlStyle: deepEqual, + chainingSystem: deepEqual, + ignoreParentSettings: deepEqual, }; subscriptions.add( @@ -71,9 +67,12 @@ export const syncDashboardControlGroup = async ({ ) ) .subscribe(() => { - const { panels, controlStyle } = controlGroup.getInput(); + const { panels, controlStyle, chainingSystem, ignoreParentSettings } = + controlGroup.getInput(); if (!isControlGroupInputEqual()) { - dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + dashboardContainer.updateInput({ + controlGroupInput: { panels, controlStyle, chainingSystem, ignoreParentSettings }, + }); } }) ); @@ -154,17 +153,17 @@ export const syncDashboardControlGroup = async ({ }; export const controlGroupInputIsEqual = ( - a: DashboardControlGroupInput | undefined, - b: DashboardControlGroupInput | undefined + a: DashboardContainerControlGroupInput | undefined, + b: DashboardContainerControlGroupInput | undefined ) => { const defaultInput = getDefaultDashboardControlGroupInput(); const inputA = { - panels: a?.panels ?? defaultInput.panels, - controlStyle: a?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(a, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; const inputB = { - panels: b?.panels ?? defaultInput.panels, - controlStyle: b?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(b, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; if (deepEqual(inputA, inputB)) return true; return false; @@ -175,7 +174,12 @@ export const serializeControlGroupToDashboardSavedObject = ( dashboardState: DashboardState ) => { // only save to saved object if control group is not default - if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) { + if ( + controlGroupInputIsEqual( + dashboardState.controlGroupInput, + getDefaultDashboardControlGroupInput() + ) + ) { dashboardSavedObject.controlGroupInput = undefined; return; } diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index eea9edd13507f..ee403939a9e8c 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -10,8 +10,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query, TimeRange } from '../../services/data'; import { ViewMode } from '../../services/embeddable'; -import type { DashboardControlGroupInput } from '../lib/dashboard_control_group'; import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; +import { DashboardContainerControlGroupInput } from '../embeddable'; export const dashboardStateSlice = createSlice({ name: 'dashboardState', @@ -44,7 +44,7 @@ export const dashboardStateSlice = createSlice({ }, setControlGroupState: ( state, - action: PayloadAction + action: PayloadAction ) => { state.controlGroupInput = action.payload; }, diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index c1023f8e900bd..575124671cf2b 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -29,7 +29,11 @@ import { UrlForwardingStart } from '../../url_forwarding/public'; import { UsageCollectionSetup } from './services/usage_collection'; import { NavigationPublicPluginStart } from './services/navigation'; import { Query, RefreshInterval, TimeRange } from './services/data'; -import { DashboardPanelState, SavedDashboardPanel } from '../common/types'; +import { + DashboardContainerControlGroupInput, + DashboardPanelState, + SavedDashboardPanel, +} from '../common/types'; import { SavedObjectsTaggingApi } from './services/saved_objects_tagging_oss'; import { DataPublicPluginStart, DataViewsContract } from './services/data'; import { ContainerInput, EmbeddableInput, ViewMode } from './services/embeddable'; @@ -40,7 +44,6 @@ import type { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; -import type { DashboardControlGroupInput } from './application/lib/dashboard_control_group'; export type { SavedDashboardPanel }; @@ -71,7 +74,7 @@ export interface DashboardState { panels: DashboardPanelMap; timeRange?: TimeRange; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; } /** @@ -81,7 +84,7 @@ export type RawDashboardState = Omit & { panels: Saved export interface DashboardContainerInput extends ContainerInput { dashboardCapabilities?: DashboardAppCapabilities; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 2ddbcfd9fdb74..69d0feffde27b 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -55,7 +55,9 @@ export const createDashboardSavedObjectType = ({ controlGroupInput: { properties: { controlStyle: { type: 'keyword', index: false, doc_values: false }, + chainingSystem: { type: 'keyword', index: false, doc_values: false }, panelsJSON: { type: 'text', index: false }, + ignoreParentSettingsJSON: { type: 'text', index: false }, }, }, timeFrom: { type: 'keyword', index: false, doc_values: false }, diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index f3759ffdb39e5..3aedd2c0a3b78 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -36,6 +36,7 @@ export type { EmbeddablePackageState, EmbeddableRendererProps, EmbeddableContainerContext, + EmbeddableContainerSettings, } from './lib'; export { ACTION_ADD_PANEL, diff --git a/src/plugins/embeddable/public/lib/containers/index.ts b/src/plugins/embeddable/public/lib/containers/index.ts index 041923188e175..655fd413e3bc0 100644 --- a/src/plugins/embeddable/public/lib/containers/index.ts +++ b/src/plugins/embeddable/public/lib/containers/index.ts @@ -6,6 +6,12 @@ * Side Public License, v 1. */ -export type { IContainer, PanelState, ContainerInput, ContainerOutput } from './i_container'; +export type { + IContainer, + PanelState, + ContainerInput, + ContainerOutput, + EmbeddableContainerSettings, +} from './i_container'; export { Container } from './container'; export * from './embeddable_child_panel'; diff --git a/test/functional/apps/dashboard/dashboard_controls_integration.ts b/test/functional/apps/dashboard/dashboard_controls_integration.ts deleted file mode 100644 index 2ccde5251250e..0000000000000 --- a/test/functional/apps/dashboard/dashboard_controls_integration.ts +++ /dev/null @@ -1,566 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const security = getService('security'); - const queryBar = getService('queryBar'); - const pieChart = getService('pieChart'); - const filterBar = getService('filterBar'); - const testSubjects = getService('testSubjects'); - const kibanaServer = getService('kibanaServer'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const find = getService('find'); - const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ - 'dashboardControls', - 'timePicker', - 'dashboard', - 'common', - 'header', - ]); - - describe('Dashboard controls integration', () => { - const clearAllControls = async () => { - const controlIds = await dashboardControls.getAllControlIds(); - for (const controlId of controlIds) { - await dashboardControls.removeExistingControl(controlId); - } - }; - - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load( - 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' - ); - await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - await kibanaServer.uiSettings.replace({ - defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', - }); - await common.navigateToApp('dashboard'); - await dashboardControls.enableControlsLab(); - await common.navigateToApp('dashboard'); - await dashboard.preserveCrossAppState(); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - await kibanaServer.savedObjects.cleanStandardList(); - }); - - describe('Controls callout visibility', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboard.saveDashboard('Test Controls Callout'); - }); - - describe('does not show the empty control callout on an empty dashboard', async () => { - it('in view mode', async () => { - await dashboard.clickCancelOutOfEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - - it('in edit mode', async () => { - await dashboard.switchToEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - }); - - it('show the empty control callout on a dashboard with panels', async () => { - await dashboard.switchToEditMode(); - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await testSubjects.existOrFail('controls-empty'); - }); - - it('adding control hides the empty control callout', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await testSubjects.missingOrFail('controls-empty'); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Control group settings', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await dashboard.saveDashboard('Test Control Group Settings'); - }); - - it('adjust layout of controls', async () => { - await dashboard.switchToEditMode(); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.adjustControlsLayout('twoLine'); - const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); - expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); - }); - - describe('apply new default size', async () => { - it('to new controls only', async () => { - await dashboardControls.updateControlsSize('medium'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - }); - - const controlIds = await dashboardControls.getAllControlIds(); - const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); - expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); - const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); - expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); - }); - - it('to all existing controls', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - width: 'large', - }); - - await dashboardControls.updateControlsSize('small', true); - const controlIds = await dashboardControls.getAllControlIds(); - for (const id of controlIds) { - const control = await find.byXPath(`//div[@data-control-id="${id}"]`); - expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); - } - }); - }); - - describe('flyout only show settings that are relevant', async () => { - before(async () => { - await dashboard.switchToEditMode(); - }); - - it('when no controls', async () => { - await dashboardControls.deleteAllControls(); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.missingOrFail('delete-all-controls-button'); - await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); - }); - - it('when at least one control', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.existOrFail('delete-all-controls-button'); - await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); - }); - - afterEach(async () => { - await testSubjects.click('euiFlyoutCloseButton'); - }); - - after(async () => { - await dashboardControls.deleteAllControls(); - }); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Options List Control creation and editing experience', async () => { - it('can add a new options list control from a blank state', async () => { - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - it('can add a second options list control with a non-default data view', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - expect(await dashboardControls.getControlsCount()).to.be(2); - - // data views should be properly propagated from the control group to the dashboard - expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); - }); - - it('renames an existing control', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - - const newTitle = 'wow! Animal sounds?'; - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlEditorSetTitle(newTitle); - await dashboardControls.controlEditorSave(); - expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); - }); - - it('can change the data view and field of an existing options list', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(firstId); - - await dashboardControls.optionsListEditorSetDataView('animals-*'); - await dashboardControls.optionsListEditorSetfield('animal.keyword'); - await dashboardControls.controlEditorSave(); - - // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await retry.try(async () => { - await testSubjects.click('addFilter'); - const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); - expect(indexPatternSelectExists).to.be(false); - }); - }); - - it('deletes an existing control', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - - await dashboardControls.removeExistingControl(firstId); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - after(async () => { - await clearAllControls(); - }); - }); - - describe('Interactions between options list and dashboard', async () => { - let controlId: string; - before(async () => { - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sounds', - }); - - controlId = (await dashboardControls.getAllControlIds())[0]; - }); - - describe('Apply dashboard query and filters to controls', async () => { - it('Applies dashboard query to options list control', async () => { - await queryBar.setQuery('isDog : true '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - ]); - }); - - await queryBar.setQuery(''); - await queryBar.submitQuery(); - }); - - it('Applies dashboard filters to options list control', async () => { - await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(3); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'bow ow ow', - ]); - }); - }); - - it('Does not apply disabled dashboard filters to options list control', async () => { - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - }); - - it('Negated filters apply to options control', async () => { - await filterBar.toggleFilterNegated('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'grrr', - 'meow', - 'growl', - 'grr', - ]); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - }); - }); - - describe('Selections made in control apply to dashboard', async () => { - it('Shows available options in options list', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can search options list for available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('meo'); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'meow', - ]); - }); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can select multiple available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('hiss'); - await dashboardControls.optionsListPopoverSelectOption('grr'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Selected options appear in control', async () => { - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - it('Applies options list control options to dashboard', async () => { - await retry.try(async () => { - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - }); - - it('Applies options list control options to dashboard by default on open', async () => { - await dashboard.gotoDashboardLandingPage(); - await header.waitUntilLoadingHasFinished(); - await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); - await header.waitUntilLoadingHasFinished(); - expect(await pieChart.getPieSliceCount()).to.be(2); - - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - after(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - }); - - describe('Options List dashboard validation', async () => { - before(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can mark selections invalid with Query', async () => { - await queryBar.setQuery('isDog : false '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(4); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'meow', - 'growl', - 'grr', - 'Ignored selection', - 'bark', - ]); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - - it('can make invalid selections valid again if the parent filter changes', async () => { - await queryBar.setQuery(''); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'ruff', - 'bark', - 'grrr', - 'meow', - 'growl', - 'grr', - 'bow ow ow', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - - it('Can mark multiple selections invalid with Filter', async () => { - await filterBar.addFilter('sound.keyword', 'is', ['hiss']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'Ignored selections', - 'meow', - 'bark', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - await clearAllControls(); - }); - }); - - describe('Control group hierarchical chaining', async () => { - let controlIds: string[]; - - const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( - expectation - ); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }; - - before(async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - title: 'Animal', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - title: 'Animal Name', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sound', - }); - - controlIds = await dashboardControls.getAllControlIds(); - }); - - it('Shows all available options in first Options List control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - }); - - it('Selecting an option in the first Options List will filter the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverSelectOption('cat'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); - await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); - }); - - it('Selecting an option in the second Options List will filter the third control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[1]); - await dashboardControls.optionsListPopoverSelectOption('sylvester'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); - - await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); - }); - - it('Can select an option in the third Options List', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[2]); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - }); - - it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListPopoverSelectOption('dog'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], [ - 'Fluffy', - 'Fee Fee', - 'Rover', - 'Ignored selection', - 'sylvester', - ]); - await ensureAvailableOptionsEql(controlIds[2], [ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - 'Ignored selection', - 'meow', - ]); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 73a8754982e4f..c9a62447f223a 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -72,7 +72,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./full_screen_mode')); loadTestFile(require.resolve('./dashboard_filter_bar')); loadTestFile(require.resolve('./dashboard_filtering')); - loadTestFile(require.resolve('./dashboard_controls_integration')); loadTestFile(require.resolve('./panel_expand_toggle')); loadTestFile(require.resolve('./dashboard_grid')); loadTestFile(require.resolve('./view_edit')); diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts new file mode 100644 index 0000000000000..13ef3a248a583 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const { dashboardControls, common, dashboard, timePicker } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group hierarchical chaining', () => { + let controlIds: string[]; + + const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + + // populate an initial set of controls and get their ids. + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + title: 'Animal Name', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sound', + }); + + controlIds = await dashboardControls.getAllControlIds(); + }); + + it('Shows all available options in first Options List control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + }); + + it('Selecting an option in the first Options List will filter the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); + }); + + it('Selecting an option in the second Options List will filter the third control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverSelectOption('sylvester'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); + + await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); + }); + + it('Can select an option in the third Options List', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Fee Fee', + 'Rover', + 'Ignored selection', + 'sylvester', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + 'Ignored selection', + 'meow', + ]); + }); + + describe('Hierarchical chaining off', async () => { + before(async () => { + await dashboardControls.updateChainingSystem('NONE'); + }); + + it('Selecting an option in the first Options List will not filter the second or third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Tiger', + 'sylvester', + 'Fee Fee', + 'Rover', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts new file mode 100644 index 0000000000000..ffda165443337 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group settings', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard('Test Control Group Settings'); + }); + + it('adjust layout of controls', async () => { + await dashboard.switchToEditMode(); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.adjustControlsLayout('twoLine'); + const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); + expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); + }); + + describe('apply new default size', async () => { + it('to new controls only', async () => { + await dashboardControls.updateControlsSize('medium'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + }); + + const controlIds = await dashboardControls.getAllControlIds(); + const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); + expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); + const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); + expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); + }); + + it('to all existing controls', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + width: 'large', + }); + + await dashboardControls.updateControlsSize('small', true); + const controlIds = await dashboardControls.getAllControlIds(); + for (const id of controlIds) { + const control = await find.byXPath(`//div[@data-control-id="${id}"]`); + expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); + } + }); + }); + + describe('flyout only show settings that are relevant', async () => { + before(async () => { + await dashboard.switchToEditMode(); + }); + + it('when no controls', async () => { + await dashboardControls.deleteAllControls(); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.missingOrFail('delete-all-controls-button'); + await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); + }); + + it('when at least one control', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.existOrFail('delete-all-controls-button'); + await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); + }); + + afterEach(async () => { + await testSubjects.click('euiFlyoutCloseButton'); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/controls_callout.ts b/test/functional/apps/dashboard_elements/controls/controls_callout.ts new file mode 100644 index 0000000000000..fc6316940c8a4 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/controls_callout.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Controls callout', () => { + describe('callout visibility', async () => { + before(async () => { + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + await dashboard.saveDashboard('Test Controls Callout'); + }); + + describe('does not show the empty control callout on an empty dashboard', async () => { + it('in view mode', async () => { + await dashboard.clickCancelOutOfEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + + it('in edit mode', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + }); + + it('show the empty control callout on a dashboard with panels', async () => { + await dashboard.switchToEditMode(); + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await testSubjects.existOrFail('controls-empty'); + }); + + it('adding control hides the empty control callout', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await testSubjects.missingOrFail('controls-empty'); + }); + + after(async () => { + await dashboard.clickCancelOutOfEditMode(); + await dashboard.gotoDashboardLandingPage(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/index.ts b/test/functional/apps/dashboard_elements/controls/index.ts new file mode 100644 index 0000000000000..a29834c848094 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + // enable the controls lab and navigate to the dashboard listing page to start + await common.navigateToApp('dashboard'); + await dashboardControls.enableControlsLab(); + await common.navigateToApp('dashboard'); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('Controls', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./controls_callout')); + loadTestFile(require.resolve('./control_group_settings')); + loadTestFile(require.resolve('./options_list')); + loadTestFile(require.resolve('./control_group_chaining')); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts new file mode 100644 index 0000000000000..6272448a68f93 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -0,0 +1,369 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const queryBar = getService('queryBar'); + const pieChart = getService('pieChart'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Dashboard options list integration', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + }); + + describe('Options List Control creation and editing experience', async () => { + it('can add a new options list control from a blank state', async () => { + await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + it('can add a second options list control with a non-default data view', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + expect(await dashboardControls.getControlsCount()).to.be(2); + + // data views should be properly propagated from the control group to the dashboard + expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); + }); + + it('renames an existing control', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + + const newTitle = 'wow! Animal sounds?'; + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle(newTitle); + await dashboardControls.controlEditorSave(); + expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); + }); + + it('can change the data view and field of an existing options list', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + + await dashboardControls.optionsListEditorSetDataView('animals-*'); + await dashboardControls.optionsListEditorSetfield('animal.keyword'); + await dashboardControls.controlEditorSave(); + + // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); + }); + + it('deletes an existing control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + + await dashboardControls.removeExistingControl(firstId); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + after(async () => { + await dashboardControls.clearAllControls(); + }); + }); + + describe('Interactions between options list and dashboard', async () => { + let controlId: string; + + const allAvailableOptions = [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]; + + const ensureAvailableOptionsEql = async (expectation: string[], skipOpen?: boolean) => { + if (!skipOpen) await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( + expectation + ); + }); + if (!skipOpen) await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + + controlId = (await dashboardControls.getAllControlIds())[0]; + }); + + describe('Applies query settings to controls', async () => { + it('Applies dashboard query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['ruff', 'bark', 'grrr', 'bow ow ow', 'grr']); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + // using the query hides the time range. Clicking anywhere else shows it again. + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Applies dashboard time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await dashboardControls.optionsListOpenPopover(controlId); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await timePicker.setDefaultDataRange(); + }); + + describe('dashboard filters', async () => { + before(async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Applies dashboard filters to options list control', async () => { + await ensureAvailableOptionsEql(['ruff', 'bark', 'bow ow ow']); + }); + + it('Does not apply disabled dashboard filters to options list control', async () => { + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(allAvailableOptions); + + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Negated filters apply to options control', async () => { + await filterBar.toggleFilterNegated('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['hiss', 'grrr', 'meow', 'growl', 'grr']); + }); + + after(async () => { + await filterBar.removeAllFilters(); + }); + }); + }); + + describe('Does not apply query settings to controls', async () => { + before(async () => { + await dashboardControls.updateAllQuerySyncSettings(false); + }); + + after(async () => { + await dashboardControls.updateAllQuerySyncSettings(true); + }); + + it('Does not apply query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await queryBar.setQuery(''); + await queryBar.submitQuery(); + }); + + it('Does not apply filters to options list control', async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await filterBar.removeAllFilters(); + }); + + it('Does not apply time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await timePicker.setDefaultDataRange(); + }); + }); + + describe('Selections made in control apply to dashboard', async () => { + it('Shows available options in options list', async () => { + await ensureAvailableOptionsEql(allAvailableOptions); + }); + + it('Can search options list for available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('meo'); + await ensureAvailableOptionsEql(['meow'], true); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Can select multiple available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('hiss'); + await dashboardControls.optionsListPopoverSelectOption('grr'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Selected options appear in control', async () => { + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + it('Applies options list control options to dashboard', async () => { + await retry.try(async () => { + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + }); + + it('Applies options list control options to dashboard by default on open', async () => { + await dashboard.gotoDashboardLandingPage(); + await header.waitUntilLoadingHasFinished(); + await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); + await header.waitUntilLoadingHasFinished(); + expect(await pieChart.getPieSliceCount()).to.be(2); + + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + }); + + describe('Options List dashboard validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await filterBar.removeAllFilters(); + }); + + it('Can mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql([ + 'hiss', + 'meow', + 'growl', + 'grr', + 'Ignored selection', + 'bark', + ]); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + + it('can make invalid selections valid again if the parent filter changes', async () => { + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + + it('Can mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'Ignored selections', 'meow', 'bark']); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + }); + + describe('Options List dashboard no validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboardControls.updateValidationSetting(false); + }); + + it('Does not mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'meow', 'growl', 'grr']); + }); + + it('Does not mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss']); + }); + }); + + after(async () => { + await filterBar.removeAllFilters(); + await dashboardControls.clearAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/index.ts b/test/functional/apps/dashboard_elements/index.ts index 4866754c3907b..059576389f32e 100644 --- a/test/functional/apps/dashboard_elements/index.ts +++ b/test/functional/apps/dashboard_elements/index.ts @@ -27,6 +27,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags('ciGroup10'); loadTestFile(require.resolve('./input_control_vis')); + loadTestFile(require.resolve('./controls')); loadTestFile(require.resolve('./_markdown_vis')); }); }); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 33053306243fe..c57c6d304e1e5 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; import { OPTIONS_LIST_CONTROL, ControlWidth } from '../../../src/plugins/controls/common'; +import { ControlGroupChainingSystem } from '../../../src/plugins/controls/common/control_group/types'; import { FtrService } from '../ftr_provider_context'; @@ -63,6 +64,13 @@ export class DashboardPageControls extends FtrService { return allTitles.length; } + public async clearAllControls() { + const controlIds = await this.getAllControlIds(); + for (const controlId of controlIds) { + await this.removeExistingControl(controlId); + } + } + public async openCreateControlFlyout(type: string) { this.log.debug(`Opening flyout for ${type} control`); await this.testSubjects.click('dashboard-controls-menu-button'); @@ -119,6 +127,85 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click('control-group-editor-save'); } + public async updateChainingSystem(chainingSystem: ControlGroupChainingSystem) { + this.log.debug(`Update control group chaining system to ${chainingSystem}`); + await this.openControlGroupSettingsFlyout(); + await this.testSubjects.existOrFail('control-group-chaining'); + // currently there are only two chaining systems, so a switch is used. + const switchStateToChainingSystem: { [key: string]: ControlGroupChainingSystem } = { + true: 'HIERARCHICAL', + false: 'NONE', + }; + + const switchState = await this.testSubjects.getAttribute('control-group-chaining', 'checked'); + if (chainingSystem !== switchStateToChainingSystem[switchState]) { + await this.testSubjects.click('control-group-chaining'); + } + await this.testSubjects.click('control-group-editor-save'); + } + + public async setSwitchState(goalState: boolean, subject: string) { + await this.testSubjects.existOrFail(subject); + const currentStateIsChecked = + (await this.testSubjects.getAttribute(subject, 'aria-checked')) === 'true'; + if (currentStateIsChecked !== goalState) { + await this.testSubjects.click(subject); + } + await this.retry.try(async () => { + const stateIsChecked = (await this.testSubjects.getAttribute(subject, 'checked')) === 'true'; + expect(stateIsChecked).to.be(goalState); + }); + } + + public async updateValidationSetting(validate: boolean) { + this.log.debug(`Update control group validation setting to ${validate}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(validate, 'control-group-validate-selections'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateAllQuerySyncSettings(querySync: boolean) { + this.log.debug(`Update all control group query sync settings to ${querySync}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(querySync, 'control-group-query-sync'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async ensureAdvancedQuerySyncIsOpened() { + const advancedAccordion = await this.testSubjects.find(`control-group-query-sync-advanced`); + const opened = await advancedAccordion.elementHasClass('euiAccordion-isOpen'); + if (!opened) { + await this.testSubjects.click(`control-group-query-sync-advanced`); + await this.retry.try(async () => { + expect(await advancedAccordion.elementHasClass('euiAccordion-isOpen')).to.be(true); + }); + } + } + + public async updateSyncTimeRangeAdvancedSetting(syncTimeRange: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncTimeRange}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncTimeRange, 'control-group-query-sync-time-range'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncQueryAdvancedSetting(syncQuery: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncQuery}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncQuery, 'control-group-query-sync-query'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncFilterAdvancedSetting(syncFilters: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncFilters}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncFilters, 'control-group-query-sync-filters'); + await this.testSubjects.click('control-group-editor-save'); + } + /* ----------------------------------------------------------- Individual controls functions ----------------------------------------------------------- */ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9d1ec062fe1b3..44cd0b1a20c8d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1221,13 +1221,9 @@ "controls.controlGroup.management.flyoutTitle": "コントロールを構成", "controls.controlGroup.management.layout.auto": "自動", "controls.controlGroup.management.layout.controlWidthLegend": "コントロールサイズを変更", - "controls.controlGroup.management.layout.designSwitchLegend": "コントロール設計を切り替え", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "1行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "2行", - "controls.controlGroup.management.layoutTitle": "レイアウト", "controls.controlGroup.management.setAllWidths": "すべてのサイズをデフォルトに設定", "controls.controlGroup.title": "コントロールグループ", "controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b055d663f9e69..abfb0a4dc2a82 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1227,13 +1227,9 @@ "controls.controlGroup.management.flyoutTitle": "配置控件", "controls.controlGroup.management.layout.auto": "自动", "controls.controlGroup.management.layout.controlWidthLegend": "更改控件大小", - "controls.controlGroup.management.layout.designSwitchLegend": "切换控件设计", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "单行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "双行", - "controls.controlGroup.management.layoutTitle": "布局", "controls.controlGroup.management.setAllWidths": "将所有大小设为默认值", "controls.controlGroup.title": "控件组", "controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选", From fe9fb3ee3d226422a4ca2aa22577122e63ec6ed5 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Wed, 23 Mar 2022 18:16:48 -0500 Subject: [PATCH 34/66] [Security Solution] update blocklist form copy (#128385) --- .../management/pages/blocklist/translations.ts | 10 +++++++++- .../pages/blocklist/view/blocklist.tsx | 6 ++++-- .../view/components/blocklist_form.tsx | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts index f7e4344cee23c..e905cef582964 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts @@ -15,7 +15,8 @@ export const DETAILS_HEADER = i18n.translate('xpack.securitySolution.blocklists. export const DETAILS_HEADER_DESCRIPTION = i18n.translate( 'xpack.securitySolution.blocklists.details.header.description', { - defaultMessage: 'Add a blocklist to prevent selected applications from running on your hosts.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', } ); @@ -61,6 +62,13 @@ export const VALUE_LABEL = i18n.translate('xpack.securitySolution.blocklists.val defaultMessage: 'Value', }); +export const VALUE_LABEL_HELPER = i18n.translate( + 'xpack.securitySolution.blocklists.value.label.helper', + { + defaultMessage: 'Type or copy & paste one or multiple comma delimited values', + } +); + export const CONDITION_FIELD_TITLE: { [K in ConditionEntryField]: string } = { [ConditionEntryField.HASH]: i18n.translate('xpack.securitySolution.blocklists.entry.field.hash', { defaultMessage: 'Hash', diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 45d76614ddce2..75d4b22fe16a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -18,14 +18,16 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', }), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.blocklist.showingTotal', { - defaultMessage: 'Showing {total} {total, plural, one {blocklist} other {blocklists}}', + defaultMessage: + 'Showing {total} {total, plural, one {blocklist entry} other {blocklist entries}}', values: { total }, }), cardActionEditLabel: i18n.translate('xpack.securitySolution.blocklist.cardActionEditLabel', { diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx index 379b8f932ba9d..ff4325a38757d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx @@ -21,6 +21,8 @@ import { EuiTitle, EuiFlexGroup, EuiFlexItem, + EuiToolTip, + EuiIcon, } from '@elastic/eui'; import { OperatingSystem, @@ -49,6 +51,7 @@ import { SELECT_OS_LABEL, VALUE_LABEL, ERRORS, + VALUE_LABEL_HELPER, } from '../../translations'; import { EffectedPolicySelect, @@ -165,6 +168,18 @@ export const BlockListForm = memo( return selectableFields; }, [selectedOs]); + const valueLabel = useMemo(() => { + return ( +
    + + <> + {VALUE_LABEL} + + +
    + ); + }, []); + const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { @@ -432,7 +447,7 @@ export const BlockListForm = memo(
    Date: Wed, 23 Mar 2022 23:04:29 -0500 Subject: [PATCH 35/66] [Security Solution] update blocklist fields to use file prefix (#128291) --- .../src/path_validations/index.ts | 16 +++- .../endpoint/types/exception_list_items.ts | 11 +-- .../utils/exception_list_items/mappers.ts | 89 +++++++++++-------- .../pages/blocklist/translations.ts | 28 +++--- .../view/components/blocklist_form.tsx | 38 ++++---- 5 files changed, 101 insertions(+), 81 deletions(-) diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index b64cb4cf6a052..665b1a0838346 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -22,6 +22,20 @@ export const enum ConditionEntryField { SIGNER = 'process.Ext.code_signature', } +export const enum EntryFieldType { + HASH = '.hash.', + EXECUTABLE = '.executable.caseless', + PATH = '.path', + SIGNER = '.Ext.code_signature', +} + +export type TrustedAppConditionEntryField = + | 'process.hash.*' + | 'process.executable.caseless' + | 'process.Ext.code_signature'; +export type BlocklistConditionEntryField = 'file.hash.*' | 'file.path' | 'file.Ext.code_signature'; +export type AllConditionEntryFields = TrustedAppConditionEntryField | BlocklistConditionEntryField; + export const enum OperatingSystem { LINUX = 'linux', MAC = 'macos', @@ -91,7 +105,7 @@ export const isPathValid = ({ value, }: { os: OperatingSystem; - field: ConditionEntryField | 'file.path.text'; + field: AllConditionEntryFields | 'file.path.text'; type: EntryTypes; value: string; }): boolean => { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts b/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts index efdbe42465a5a..bcb452abd50e0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts @@ -5,17 +5,14 @@ * 2.0. */ -import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils'; +import { AllConditionEntryFields, EntryTypes } from '@kbn/securitysolution-utils'; export type ConditionEntriesMap = { - [K in ConditionEntryField]?: T; + [K in AllConditionEntryFields]?: T; }; -export interface ConditionEntry< - F extends ConditionEntryField = ConditionEntryField, - T extends EntryTypes = EntryTypes -> { - field: F; +export interface ConditionEntry { + field: AllConditionEntryFields; type: T; operator: 'included'; value: string | string[]; diff --git a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts index e04d059a515d4..bfd844caad1b4 100644 --- a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts @@ -13,7 +13,7 @@ import { EntryNested, NestedEntriesArray, } from '@kbn/securitysolution-io-ts-list-types'; -import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils'; +import { AllConditionEntryFields, EntryFieldType, EntryTypes } from '@kbn/securitysolution-utils'; import { ConditionEntriesMap, ConditionEntry } from '../../../../common/endpoint/types'; @@ -46,12 +46,12 @@ const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNes return { field, entries, type: 'nested' }; }; -function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { +function groupHashEntry(prefix: 'process' | 'file', conditionEntry: ConditionEntry): EntriesArray { const entriesArray: EntriesArray = []; if (!Array.isArray(conditionEntry.value)) { const entry = createEntryMatch( - `process.hash.${hashType(conditionEntry.value)}`, + `${prefix}${EntryFieldType.HASH}${hashType(conditionEntry.value)}`, conditionEntry.value.toLowerCase() ); entriesArray.push(entry); @@ -80,7 +80,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { return; } - const entry = createEntryMatchAny(`process.hash.${type}`, values); + const entry = createEntryMatchAny(`${prefix}${EntryFieldType.HASH}${type}`, values); entriesArray.push(entry); }); @@ -88,6 +88,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { } function createNestedSignatureEntry( + field: AllConditionEntryFields, value: string | string[], isTrustedApp: boolean = false ): EntryNested { @@ -97,19 +98,23 @@ function createNestedSignatureEntry( const nestedEntries: EntryNested['entries'] = []; if (isTrustedApp) nestedEntries.push(createEntryMatch('trusted', 'true')); nestedEntries.push(subjectNameMatch); - return createEntryNested('process.Ext.code_signature', nestedEntries); + return createEntryNested(field, nestedEntries); } -function createWildcardPathEntry(value: string | string[]): EntryMatchWildcard | EntryMatchAny { +function createWildcardPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatchWildcard | EntryMatchAny { return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatchWildcard('process.executable.caseless', value); + ? createEntryMatchAny(field, value) + : createEntryMatchWildcard(field, value); } -function createPathEntry(value: string | string[]): EntryMatch | EntryMatchAny { - return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatch('process.executable.caseless', value); +function createPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatch | EntryMatchAny { + return Array.isArray(value) ? createEntryMatchAny(field, value) : createEntryMatch(field, value); } export const conditionEntriesToEntries = ( @@ -119,19 +124,25 @@ export const conditionEntriesToEntries = ( const entriesArray: EntriesArray = []; conditionEntries.forEach((conditionEntry) => { - if (conditionEntry.field === ConditionEntryField.HASH) { - groupHashEntry(conditionEntry).forEach((entry) => entriesArray.push(entry)); - } else if (conditionEntry.field === ConditionEntryField.SIGNER) { - const entry = createNestedSignatureEntry(conditionEntry.value, isTrustedApp); + if (conditionEntry.field.includes(EntryFieldType.HASH)) { + const prefix = conditionEntry.field.split('.')[0] as 'process' | 'file'; + groupHashEntry(prefix, conditionEntry).forEach((entry) => entriesArray.push(entry)); + } else if (conditionEntry.field.includes(EntryFieldType.SIGNER)) { + const entry = createNestedSignatureEntry( + conditionEntry.field, + conditionEntry.value, + isTrustedApp + ); entriesArray.push(entry); } else if ( - conditionEntry.field === ConditionEntryField.PATH && + (conditionEntry.field.includes(EntryFieldType.EXECUTABLE) || + conditionEntry.field.includes(EntryFieldType.PATH)) && conditionEntry.type === 'wildcard' ) { - const entry = createWildcardPathEntry(conditionEntry.value); + const entry = createWildcardPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } else { - const entry = createPathEntry(conditionEntry.value); + const entry = createPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } }); @@ -140,49 +151,51 @@ export const conditionEntriesToEntries = ( }; const createConditionEntry = ( - field: ConditionEntryField, + field: AllConditionEntryFields, type: EntryTypes, value: string | string[] ): ConditionEntry => { return { field, value, type, operator: OPERATOR_VALUE }; }; +function createWildcardHashField( + field: string +): Extract { + const prefix = field.split('.')[0] as 'process' | 'file'; + return `${prefix}${EntryFieldType.HASH}*`; +} + export const entriesToConditionEntriesMap = ( entries: EntriesArray ): ConditionEntriesMap => { return entries.reduce((memo: ConditionEntriesMap, entry) => { - if (entry.field.startsWith('process.hash') && entry.type === 'match') { + const field = entry.field as AllConditionEntryFields; + if (field.includes(EntryFieldType.HASH) && entry.type === 'match') { + const wildcardHashField = createWildcardHashField(field); return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry( - ConditionEntryField.HASH, - entry.type, - entry.value - ), + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field.startsWith('process.hash') && entry.type === 'match_any') { - const currentValues = (memo[ConditionEntryField.HASH]?.value as string[]) ?? []; + } else if (field.includes(EntryFieldType.HASH) && entry.type === 'match_any') { + const wildcardHashField = createWildcardHashField(field); + const currentValues = (memo[wildcardHashField]?.value as string[]) ?? []; return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry(ConditionEntryField.HASH, entry.type, [ + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, [ ...currentValues, ...entry.value, ]), } as ConditionEntriesMap; } else if ( - entry.field === ConditionEntryField.PATH && + (field.includes(EntryFieldType.EXECUTABLE) || field.includes(EntryFieldType.PATH)) && (entry.type === 'match' || entry.type === 'match_any' || entry.type === 'wildcard') ) { return { ...memo, - [ConditionEntryField.PATH]: createConditionEntry( - ConditionEntryField.PATH, - entry.type, - entry.value - ), + [field]: createConditionEntry(field, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field === ConditionEntryField.SIGNER && entry.type === 'nested') { + } else if (field.includes(EntryFieldType.SIGNER) && entry.type === 'nested') { const subjectNameCondition = entry.entries.find((subEntry): subEntry is EntryMatch => { return ( subEntry.field === 'subject_name' && @@ -193,8 +206,8 @@ export const entriesToConditionEntriesMap = {message}
    ; } -function getDropdownDisplay(field: ConditionEntryField): React.ReactNode { +function getDropdownDisplay(field: BlocklistConditionEntryField): React.ReactNode { return ( <> {CONDITION_FIELD_TITLE[field]} @@ -118,7 +118,7 @@ export const BlockListForm = memo( const blocklistEntry = useMemo((): BlocklistEntry => { if (!item.entries.length) { return { - field: ConditionEntryField.HASH, + field: 'file.hash.*', operator: 'included', type: 'match_any', value: [], @@ -148,20 +148,19 @@ export const BlockListForm = memo( [] ); - const fieldOptions: Array> = useMemo(() => { - const selectableFields: Array> = [ - ConditionEntryField.HASH, - ConditionEntryField.PATH, - ].map((field) => ({ + const fieldOptions: Array> = useMemo(() => { + const selectableFields: Array> = ( + ['file.hash.*', 'file.path'] as BlocklistConditionEntryField[] + ).map((field) => ({ value: field, inputDisplay: CONDITION_FIELD_TITLE[field], dropdownDisplay: getDropdownDisplay(field), })); if (selectedOs === OperatingSystem.WINDOWS) { selectableFields.push({ - value: ConditionEntryField.SIGNER, - inputDisplay: CONDITION_FIELD_TITLE[ConditionEntryField.SIGNER], - dropdownDisplay: getDropdownDisplay(ConditionEntryField.SIGNER), + value: 'file.Ext.code_signature', + inputDisplay: CONDITION_FIELD_TITLE['file.Ext.code_signature'], + dropdownDisplay: getDropdownDisplay('file.Ext.code_signature'), }); } @@ -183,7 +182,7 @@ export const BlockListForm = memo( const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { - field = ConditionEntryField.HASH, + field = 'file.hash.*', type = 'match_any', value: values = [], } = (nextItem.entries[0] ?? {}) as BlocklistEntry; @@ -203,20 +202,20 @@ export const BlockListForm = memo( } // error if invalid hash - if (field === ConditionEntryField.HASH && values.some((value) => !isValidHash(value))) { + if (field === 'file.hash.*' && values.some((value) => !isValidHash(value))) { newValueErrors.push(createValidationMessage(ERRORS.INVALID_HASH)); } const isInvalidPath = values.some((value) => !isPathValid({ os, field, type, value })); // warn if invalid path - if (field !== ConditionEntryField.HASH && isInvalidPath) { + if (field !== 'file.hash.*' && isInvalidPath) { newValueWarnings.push(createValidationMessage(ERRORS.INVALID_PATH)); } // warn if wildcard if ( - field !== ConditionEntryField.HASH && + field !== 'file.hash.*' && !isInvalidPath && values.some((value) => !hasSimpleExecutableName({ os, type, value })) ) { @@ -275,9 +274,8 @@ export const BlockListForm = memo( { ...blocklistEntry, field: - os !== OperatingSystem.WINDOWS && - blocklistEntry.field === ConditionEntryField.SIGNER - ? ConditionEntryField.HASH + os !== OperatingSystem.WINDOWS && blocklistEntry.field === 'file.Ext.code_signature' + ? 'file.hash.*' : blocklistEntry.field, }, ], @@ -293,7 +291,7 @@ export const BlockListForm = memo( ); const handleOnFieldChange = useCallback( - (field: ConditionEntryField) => { + (field: BlocklistConditionEntryField) => { const nextItem = { ...item, entries: [{ ...blocklistEntry, field }], From 2d12c94c2f03f648293ce2c8429fe8d4fc4f3789 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 23 Mar 2022 22:56:48 -0700 Subject: [PATCH 36/66] Allow add_prepackaged_rules to change rule types (#128283) --- .../rules/add_prepackaged_rules_route.ts | 6 +- .../detection_engine/rules/create_rules.ts | 12 +- .../lib/detection_engine/rules/types.ts | 1 + .../rules/update_prepacked_rules.test.ts | 11 +- .../rules/update_prepacked_rules.ts | 195 ++++++++++++------ .../server/lib/detection_engine/types.ts | 1 + 6 files changed, 156 insertions(+), 70 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 691548c0a9efd..d5c6c0da2cec7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -73,7 +73,7 @@ export const addPrepackedRulesRoute = (router: SecuritySolutionPluginRouter) => ); }; -class PrepackagedRulesError extends Error { +export class PrepackagedRulesError extends Error { public readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); @@ -147,10 +147,10 @@ export const createPrepackagedRules = async ( await updatePrepackagedRules( rulesClient, savedObjectsClient, - context.getSpaceId(), rulesToUpdate, signalsIndex, - ruleRegistryEnabled + ruleRegistryEnabled, + context.getRuleExecutionLog() ); const prepackagedRulesOutput: PrePackagedRulesAndTimelinesSchema = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 5ff5358fbc4cd..ef9d198d2040f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -12,7 +12,7 @@ import { normalizeThresholdObject, } from '../../../../common/detection_engine/utils'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; -import { SanitizedAlert } from '../../../../../alerting/common'; +import { AlertTypeParams, SanitizedAlert } from '../../../../../alerting/common'; import { DEFAULT_INDICATOR_SOURCE_PATH, NOTIFICATION_THROTTLE_NO_ACTIONS, @@ -20,7 +20,7 @@ import { } from '../../../../common/constants'; import { CreateRulesOptions } from './types'; import { addTags } from './add_tags'; -import { PartialFilter, RuleTypeParams } from '../types'; +import { PartialFilter } from '../types'; import { transformToAlertThrottle, transformToNotifyWhen } from './utils'; export const createRules = async ({ @@ -76,8 +76,12 @@ export const createRules = async ({ exceptionsList, actions, isRuleRegistryEnabled, -}: CreateRulesOptions): Promise> => { - const rule = await rulesClient.create({ + id, +}: CreateRulesOptions): Promise> => { + const rule = await rulesClient.create({ + options: { + id, + }, data: { name, tags: addTags(tags, ruleId, immutable), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 74fb5bfe672a0..7e66f1d0aa7a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -194,6 +194,7 @@ export interface CreateRulesOptions { actions: RuleAlertAction[]; isRuleRegistryEnabled: boolean; namespace?: NamespaceOrUndefined; + id?: string; } export interface UpdateRulesOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index 703b0a4f5aec1..44a7fa58a385f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit } from '../routes/__mocks__/request_response import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { ruleExecutionLogMock } from '../rule_execution_log/__mocks__'; jest.mock('./patch_rules'); @@ -20,10 +21,12 @@ describe.each([ ])('updatePrepackagedRules - %s', (_, isRuleRegistryEnabled) => { let rulesClient: ReturnType; let savedObjectsClient: ReturnType; + let ruleExecutionLog: ReturnType; beforeEach(() => { rulesClient = rulesClientMock.create(); savedObjectsClient = savedObjectsClientMock.create(); + ruleExecutionLog = ruleExecutionLogMock.forRoutes.create(); }); it('should omit actions and enabled when calling patchRules', async () => { @@ -42,10 +45,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, actions }], outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( @@ -73,10 +76,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, ...updatedThreatParams }], 'output-index', - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index f6b4508405c5e..ceb6a3739bd6c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -15,6 +15,11 @@ import { readRules } from './read_rules'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { legacyMigrate } from './utils'; +import { deleteRules } from './delete_rules'; +import { PrepackagedRulesError } from '../routes/rules/add_prepackaged_rules_route'; +import { IRuleExecutionLogForRoutes } from '../rule_execution_log'; +import { createRules } from './create_rules'; +import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions'; /** * Updates the prepackaged rules given a set of rules and output index. @@ -28,20 +33,20 @@ import { legacyMigrate } from './utils'; export const updatePrepackagedRules = async ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Promise => { const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, savedObjectsClient, - spaceId, ruleChunk, outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); await Promise.all(rulePromises); } @@ -58,10 +63,10 @@ export const updatePrepackagedRules = async ( export const createPromises = ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Array | null>> => { return rules.map(async (rule) => { const { @@ -128,58 +133,130 @@ export const createPromises = ( rule: existingRule, }); - // Note: we do not pass down enabled as we do not want to suddenly disable - // or enable rules on the user when they were not expecting it if a rule updates - return patchRules({ - rulesClient, - author, - buildingBlockType, - description, - eventCategoryOverride, - falsePositives, - from, - query, - language, - license, - outputIndex, - rule: migratedRule, - savedId, - meta, - filters, - index, - interval, - maxSignals, - riskScore, - riskScoreMapping, - ruleNameOverride, - name, - severity, - severityMapping, - tags, - timestampOverride, - to, - type, - threat, - threshold, - threatFilters, - threatIndex, - threatIndicatorPath, - threatQuery, - threatMapping, - threatLanguage, - concurrentSearches, - itemsPerSearch, - references, - version, - note, - anomalyThreshold, - enabled: undefined, - timelineId, - timelineTitle, - machineLearningJobId, - exceptionsList, - throttle, - actions: undefined, - }); + if (!migratedRule) { + throw new PrepackagedRulesError(`Failed to find rule ${ruleId}`, 500); + } + + // If we're trying to change the type of a prepackaged rule, we need to delete the old one + // and replace it with the new rule, keeping the enabled setting, actions, throttle, id, + // and exception lists from the old rule + if (type !== migratedRule.params.type) { + await deleteRules({ + ruleId: migratedRule.id, + rulesClient, + ruleExecutionLog, + }); + + return (await createRules({ + id: migratedRule.id, + isRuleRegistryEnabled, + rulesClient, + anomalyThreshold, + author, + buildingBlockType, + description, + enabled: migratedRule.enabled, // Enabled comes from existing rule + eventCategoryOverride, + falsePositives, + from, + immutable: true, // At the moment we force all prepackaged rules to be immutable + query, + language, + license, + machineLearningJobId, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + to, + type, + threat, + threatFilters, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + threatQuery, + threatIndex, + threatIndicatorPath, + threshold, + throttle: migratedRule.throttle, // Throttle comes from the existing rule + timestampOverride, + references, + note, + version, + // The exceptions list passed in to this function has already been merged with the exceptions list of + // the existing rule + exceptionsList, + actions: migratedRule.actions.map(transformAlertToRuleAction), // Actions come from the existing rule + })) as PartialAlert; // TODO: Replace AddPrepackagedRulesSchema with type specific rules schema so we can clean up these types + } else { + // Note: we do not pass down enabled as we do not want to suddenly disable + // or enable rules on the user when they were not expecting it if a rule updates + return patchRules({ + rulesClient, + author, + buildingBlockType, + description, + eventCategoryOverride, + falsePositives, + from, + query, + language, + license, + outputIndex, + rule: migratedRule, + savedId, + meta, + filters, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + timestampOverride, + to, + type, + threat, + threshold, + threatFilters, + threatIndex, + threatIndicatorPath, + threatQuery, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + references, + version, + note, + anomalyThreshold, + enabled: undefined, + timelineId, + timelineTitle, + machineLearningJobId, + exceptionsList, + throttle, + actions: undefined, + }); + } }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 206ccb3b78351..f25c23d2d5ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -80,6 +80,7 @@ export interface RuleTypeParams extends AlertTypeParams { query?: QueryOrUndefined; filters?: unknown[]; maxSignals: MaxSignals; + namespace?: string; riskScore: RiskScore; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; From 968f350989c42054b465ee77f40d8aa3fcc597a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 24 Mar 2022 08:23:21 +0100 Subject: [PATCH 37/66] Create generic get filter method to be used with an array of list id's (#127983) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/typescript_types/index.ts | 4 +- .../src/use_exception_lists/index.ts | 14 +- .../get_event_filters_filter/index.test.ts | 39 -- .../src/get_event_filters_filter/index.ts | 27 -- .../src/get_filters/index.test.ts | 333 +++--------------- .../src/get_filters/index.ts | 31 +- .../index.test.ts | 49 --- .../index.ts | 27 -- .../src/get_trusted_apps_filter/index.test.ts | 39 -- .../src/get_trusted_apps_filter/index.ts | 27 -- .../src/index.ts | 1 - .../hooks/use_exception_lists.test.ts | 231 +----------- .../rules/all/exceptions/exceptions_table.tsx | 5 +- 13 files changed, 77 insertions(+), 750 deletions(-) delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index bf3d066d59f25..a5eb4f976debd 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -41,9 +41,7 @@ export interface UseExceptionListsProps { namespaceTypes: NamespaceType[]; notifications: NotificationsStart; initialPagination?: Pagination; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists?: readonly string[]; } export interface UseExceptionListProps { diff --git a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts index 55c1d4dfaa853..c73405f1950b8 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts @@ -39,9 +39,7 @@ const DEFAULT_PAGINATION = { * @param filterOptions filter by certain fields * @param namespaceTypes spaces to be searched * @param notifications kibana service for displaying toasters - * @param showTrustedApps boolean - include/exclude trusted app lists - * @param showEventFilters boolean - include/exclude event filters lists - * @param showHostIsolationExceptions boolean - include/exclude host isolation exceptions lists + * @param hideLists a list of listIds we don't want to query * @param initialPagination * */ @@ -52,9 +50,7 @@ export const useExceptionLists = ({ filterOptions = {}, namespaceTypes, notifications, - showTrustedApps = false, - showEventFilters = false, - showHostIsolationExceptions = false, + hideLists = [], }: UseExceptionListsProps): ReturnExceptionLists => { const [exceptionLists, setExceptionLists] = useState([]); const [pagination, setPagination] = useState(initialPagination); @@ -67,11 +63,9 @@ export const useExceptionLists = ({ getFilters({ filters: filterOptions, namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, + hideLists, }), - [namespaceTypes, filterOptions, showTrustedApps, showEventFilters, showHostIsolationExceptions] + [namespaceTypes, filterOptions, hideLists] ); const fetchData = useCallback(async (): Promise => { diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts deleted file mode 100644 index 934a9cbff56a6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getEventFiltersFilter } from '.'; - -describe('getEventFiltersFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts deleted file mode 100644 index 7e55073228fca..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getEventFiltersFilter = ( - showEventFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showEventFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts index 6484ac002d56d..8636984135792 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts @@ -10,423 +10,198 @@ import { getFilters } from '.'; describe('getFilters', () => { describe('single', () => { - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list.attributes.list_id: listId-1*)'); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample)' ); }); }); describe('agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list-agnostic.attributes.list_id: listId-1*)'); }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample)' ); }); }); describe('single, agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it properly formats when filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample)' ); }); }); diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts index e8e9e6a581828..214fd396d0918 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts @@ -9,34 +9,23 @@ import { ExceptionListFilter, NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { getGeneralFilters } from '../get_general_filters'; import { getSavedObjectTypes } from '../get_saved_object_types'; -import { getTrustedAppsFilter } from '../get_trusted_apps_filter'; -import { getEventFiltersFilter } from '../get_event_filters_filter'; -import { getHostIsolationExceptionsFilter } from '../get_host_isolation_exceptions_filter'; - export interface GetFiltersParams { filters: ExceptionListFilter; namespaceTypes: NamespaceType[]; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists: readonly string[]; } -export const getFilters = ({ - filters, - namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, -}: GetFiltersParams): string => { +export const getFilters = ({ filters, namespaceTypes, hideLists }: GetFiltersParams): string => { const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); const generalFilters = getGeneralFilters(filters, namespaces); - const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); - const eventFiltersFilter = getEventFiltersFilter(showEventFilters, namespaces); - const hostIsolationExceptionsFilter = getHostIsolationExceptionsFilter( - showHostIsolationExceptions, - namespaces - ); - return [generalFilters, trustedAppsFilter, eventFiltersFilter, hostIsolationExceptionsFilter] + const hideListsFilters = hideLists.map((listId) => { + const filtersByNamespace = namespaces.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${listId}*`; + }); + return `(${filtersByNamespace.join(' AND ')})`; + }); + + return [generalFilters, ...hideListsFilters] .filter((filter) => filter.trim() !== '') .join(' AND '); }; diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts deleted file mode 100644 index 30466f459cf65..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getHostIsolationExceptionsFilter } from '.'; - -describe('getHostIsolationExceptionsFilter', () => { - test('it returns filter to search for "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, ['exception-list']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, ['exception-list']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts deleted file mode 100644 index d61f8fe7dac19..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getHostIsolationExceptionsFilter = ( - showFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts deleted file mode 100644 index da178b15390e6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getTrustedAppsFilter } from '.'; - -describe('getTrustedAppsFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts deleted file mode 100644 index 9c969068d4edf..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getTrustedAppsFilter = ( - showTrustedApps: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showTrustedApps) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/index.ts b/packages/kbn-securitysolution-list-utils/src/index.ts index 9e88cac6b5d19..a9fb3d9c3dbc7 100644 --- a/packages/kbn-securitysolution-list-utils/src/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/index.ts @@ -13,7 +13,6 @@ export * from './get_general_filters'; export * from './get_ids_and_namespaces'; export * from './get_saved_object_type'; export * from './get_saved_object_types'; -export * from './get_trusted_apps_filter'; export * from './has_large_value_list'; export * from './helpers'; export * from './types'; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index bb4ad821b39cc..69b157835e882 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -48,9 +48,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); await waitForNextUpdate(); @@ -86,9 +83,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -112,7 +106,7 @@ describe('useExceptionLists', () => { }); }); - test('fetches trusted apps lists if "showTrustedApps" is true', async () => { + test('does not fetch specific list id if it is added to the hideLists array', async () => { const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); await act(async () => { @@ -120,6 +114,7 @@ describe('useExceptionLists', () => { useExceptionLists({ errorMessage: 'Uh oh', filterOptions: {}, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -128,9 +123,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: true, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -140,192 +132,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches event filters lists if "showEventFilters" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: true, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch event filters lists if "showEventFilters" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches host isolation exceptions lists if "hostIsolationExceptionsFilter" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: true, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch host isolation exceptions lists if "showHostIsolationExceptions" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -345,6 +152,7 @@ describe('useExceptionLists', () => { created_by: 'Moi', name: 'Sample Endpoint', }, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -353,9 +161,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -365,7 +170,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -381,16 +186,7 @@ describe('useExceptionLists', () => { UseExceptionListsProps, ReturnExceptionLists >( - ({ - errorMessage, - filterOptions, - http, - initialPagination, - namespaceTypes, - notifications, - showEventFilters, - showTrustedApps, - }) => + ({ errorMessage, filterOptions, http, initialPagination, namespaceTypes, notifications }) => useExceptionLists({ errorMessage, filterOptions, @@ -398,9 +194,6 @@ describe('useExceptionLists', () => { initialPagination, namespaceTypes, notifications, - showEventFilters, - showHostIsolationExceptions: false, - showTrustedApps, }), { initialProps: { @@ -414,9 +207,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }, } ); @@ -436,9 +226,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }); // NOTE: Only need one call here because hook already initilaized await waitForNextUpdate(); @@ -465,9 +252,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -505,9 +289,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 65684a7c7d9de..72984a8bcbe92 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -40,6 +40,7 @@ import { userHasPermissions } from '../../helpers'; import { useListsConfig } from '../../../../../containers/detection_engine/lists/use_lists_config'; import { ExceptionsTableItem } from './types'; import { MissingPrivilegesCallOut } from '../../../../../components/callouts/missing_privileges_callout'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../../../../common/endpoint/service/artifacts/constants'; export type Func = () => Promise; @@ -84,9 +85,7 @@ export const ExceptionListsTable = React.memo(() => { http, namespaceTypes: ['single', 'agnostic'], notifications, - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS, }); const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({ exceptionLists: exceptions ?? [], From f289a5d78b278466172d0baec25792f9a3a7286d Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Thu, 24 Mar 2022 09:31:40 +0100 Subject: [PATCH 38/66] Add Events tab and External alerts tab to the User page and the User details page (#127953) * Add Events tab to the User page and the User details page * Add External alerts tab to the User page and the User details page * Add cypress tests * Add unit test to EventsQueryTabBody * Memoize navTabs on Users page --- .../common/types/timeline/index.ts | 4 + .../users/users_events_tab.spec.ts | 26 ++++ .../users/users_external_alerts_tab.spec.ts | 29 +++++ .../cypress/screens/users/user_events.ts | 9 ++ .../screens/users/user_external_alerts.ts | 9 ++ .../events_tab/events_query_tab_body.test.tsx | 114 ++++++++++++++++++ .../events_tab}/events_query_tab_body.tsx | 46 ++++--- .../public/common/mock/global_state.ts | 5 +- .../hosts/pages/details/details_tabs.tsx | 9 +- .../public/hosts/pages/hosts_tabs.tsx | 6 +- .../public/hosts/pages/navigation/index.ts | 1 - .../components/events_by_dataset/index.tsx | 6 +- .../user_risk_score_table/index.tsx | 1 + .../public/users/pages/constants.ts | 4 +- .../users/pages/details/details_tabs.tsx | 31 ++++- .../public/users/pages/details/helpers.ts | 32 +++++ .../public/users/pages/details/index.tsx | 8 +- .../public/users/pages/details/nav_tabs.tsx | 24 +++- .../public/users/pages/details/types.ts | 10 +- .../public/users/pages/details/utils.ts | 2 + .../public/users/pages/index.tsx | 83 +++++++------ .../public/users/pages/nav_tabs.tsx | 12 ++ .../navigation/all_users_query_tab_body.tsx | 5 +- .../public/users/pages/navigation/types.ts | 6 +- .../public/users/pages/translations.ts | 14 +++ .../public/users/pages/users.tsx | 8 +- .../public/users/pages/users_tabs.tsx | 14 +++ .../public/users/store/actions.ts | 1 + .../public/users/store/model.ts | 6 + .../public/users/store/reducer.ts | 22 +++- .../timelines/common/types/timeline/index.ts | 2 + .../timelines/public/store/t_grid/types.ts | 2 + 32 files changed, 472 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/users/users_events_tab.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/users/user_events.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx rename x-pack/plugins/security_solution/public/{hosts/pages/navigation => common/components/events_tab}/events_query_tab_body.tsx (72%) 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 ab60d87973983..5e933efbbc61d 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -314,6 +314,8 @@ export type TimelineWithoutExternalRefs = Omit { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders events tab`, () => { + cy.get(EVENTS_TAB).click(); + + cy.get(EVENTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts new file mode 100644 index 0000000000000..a2b62bc892032 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EXTERNAL_ALERTS_TAB, + EXTERNAL_ALERTS_TAB_CONTENT, +} from '../../screens/users/user_external_alerts'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users external alerts tab', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders external alerts tab`, () => { + cy.get(EXTERNAL_ALERTS_TAB).click(); + + cy.get(EXTERNAL_ALERTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts new file mode 100644 index 0000000000000..c2bcd30f9d1c2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EVENTS_TAB = '[data-test-subj="navigation-events"]'; +export const EVENTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts new file mode 100644 index 0000000000000..bc98b3bc59f37 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EXTERNAL_ALERTS_TAB = '[data-test-subj="navigation-externalAlerts"]'; +export const EXTERNAL_ALERTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx new file mode 100644 index 0000000000000..7abca14a2e55f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TimelineId } from '../../../../common/types'; +import { HostsType } from '../../../hosts/store/model'; +import { TestProviders } from '../../mock'; +import { EventsQueryTabBody, EventsQueryTabBodyComponentProps } from './events_query_tab_body'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as tGridActions from '../../../../../timelines/public/store/t_grid/actions'; + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn(), + }, + }, + }, + }), + }; +}); + +const FakeStatefulEventsViewer = () =>
    {'MockedStatefulEventsViewer'}
    ; +jest.mock('../events_viewer', () => ({ StatefulEventsViewer: FakeStatefulEventsViewer })); + +jest.mock('../../containers/use_full_screen', () => ({ + useGlobalFullScreen: jest.fn().mockReturnValue({ + globalFullScreen: true, + }), +})); + +describe('EventsQueryTabBody', () => { + const commonProps: EventsQueryTabBodyComponentProps = { + indexNames: ['test-index'], + setQuery: jest.fn(), + timelineId: TimelineId.test, + type: HostsType.page, + endDate: new Date('2000').toISOString(), + startDate: new Date('2000').toISOString(), + }; + + it('renders EventsViewer', () => { + const { queryByText } = render( + + + + ); + + expect(queryByText('MockedStatefulEventsViewer')).toBeInTheDocument(); + }); + + it('renders the matrix histogram when globalFullScreen is false', () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: false, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).toBeInTheDocument(); + }); + + it("doesn't render the matrix histogram when globalFullScreen is true", () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: true, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).not.toBeInTheDocument(); + }); + + it('deletes query when unmouting', () => { + const mockDeleteQuery = jest.fn(); + const { unmount } = render( + + + + ); + unmount(); + + expect(mockDeleteQuery).toHaveBeenCalled(); + }); + + it('initializes t-grid', () => { + const spy = jest.spyOn(tGridActions, 'initializeTGridSettings'); + render( + + + + ); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx similarity index 72% rename from x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx rename to x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index 59c3322fb02ed..cfd6546470d4a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -8,27 +8,28 @@ import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { Filter } from '@kbn/es-query'; import { TimelineId } from '../../../../common/types/timeline'; -import { StatefulEventsViewer } from '../../../common/components/events_viewer'; +import { StatefulEventsViewer } from '../events_viewer'; import { timelineActions } from '../../../timelines/store/timeline'; -import { HostsComponentsQueryProps } from './types'; -import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; -import { - MatrixHistogramOption, - MatrixHistogramConfigs, -} from '../../../common/components/matrix_histogram/types'; -import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; -import * as i18n from '../translations'; +import { eventsDefaultModel } from '../events_viewer/default_model'; + +import { MatrixHistogram } from '../matrix_histogram'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as i18n from '../../../hosts/pages/translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; import { getEventsHistogramLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/events'; +import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; +import { GlobalTimeArgs } from '../../containers/use_global_time'; +import { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types'; +import { QueryTabBodyProps as UserQueryTabBodyProps } from '../../../users/pages/navigation/types'; +import { QueryTabBodyProps as HostQueryTabBodyProps } from '../../../hosts/pages/navigation/types'; const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery'; @@ -61,7 +62,17 @@ export const histogramConfigs: MatrixHistogramConfigs = { getLensAttributes: getEventsHistogramLensAttributes, }; -const EventsQueryTabBodyComponent: React.FC = ({ +type QueryTabBodyProps = UserQueryTabBodyProps | HostQueryTabBodyProps; + +export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & { + deleteQuery?: GlobalTimeArgs['deleteQuery']; + indexNames: string[]; + pageFilters?: Filter[]; + setQuery: GlobalTimeArgs['setQuery']; + timelineId: TimelineId; +}; + +const EventsQueryTabBodyComponent: React.FC = ({ deleteQuery, endDate, filterQuery, @@ -69,6 +80,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ pageFilters, setQuery, startDate, + timelineId, }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); @@ -78,7 +90,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ - id: TimelineId.hostsPageEvents, + id: timelineId, defaultColumns: eventsDefaultModel.columns.map((c) => !tGridEnabled && c.initialWidth == null ? { @@ -89,7 +101,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ ), }) ); - }, [dispatch, tGridEnabled]); + }, [dispatch, tGridEnabled, timelineId]); useEffect(() => { return () => { @@ -119,7 +131,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ defaultModel={eventsDefaultModel} end={endDate} entityType="events" - id={TimelineId.hostsPageEvents} + id={timelineId} leadingControlColumns={leadingControlColumns} pageFilters={pageFilters} renderCellValue={DefaultCellRenderer} diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 7795e76c5fbbb..52f0b1a682097 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -203,7 +203,6 @@ export const mockGlobalState: State = { [usersModel.UsersTableType.allUsers]: { activePage: 0, limit: 10, - // TODO sort: { field: RiskScoreFields.riskScore, direction: Direction.desc }, }, [usersModel.UsersTableType.anomalies]: null, [usersModel.UsersTableType.risk]: { @@ -215,11 +214,15 @@ export const mockGlobalState: State = { }, severitySelection: [], }, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, details: { queries: { [usersModel.UsersTableType.anomalies]: null, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx index 891db470161d4..142f3b922f842 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx @@ -15,6 +15,7 @@ import { HostsTableType } from '../../store/model'; import { AnomaliesQueryTabBody } from '../../../common/containers/anomalies/anomalies_query_tab_body'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { AnomaliesHostTable } from '../../../common/components/ml/tables/anomalies_host_table'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; import { HostDetailsTabsProps } from './types'; import { type } from './utils'; @@ -23,10 +24,10 @@ import { HostsQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, HostAlertsQueryTabBody, HostRiskTabBody, } from '../navigation'; +import { TimelineId } from '../../../../common/types'; export const HostDetailsTabs = React.memo( ({ @@ -98,7 +99,11 @@ export const HostDetailsTabs = React.memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx index 07979c289309a..d7c615c08ec28 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx @@ -15,15 +15,17 @@ import { HostsTableType } from '../store/model'; import { AnomaliesQueryTabBody } from '../../common/containers/anomalies/anomalies_query_tab_body'; import { AnomaliesHostTable } from '../../common/components/ml/tables/anomalies_host_table'; import { UpdateDateRange } from '../../common/components/charts/common'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; import { HOSTS_PATH } from '../../../common/constants'; + import { HostsQueryTabBody, HostRiskScoreQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, } from './navigation'; import { HostAlertsQueryTabBody } from './navigation/alerts_query_tab_body'; +import { TimelineId } from '../../../common/types'; export const HostsTabs = memo( ({ @@ -96,7 +98,7 @@ export const HostsTabs = memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts index 6b74418549164..3ef211e1aef33 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts @@ -6,7 +6,6 @@ */ export * from './authentications_query_tab_body'; -export * from './events_query_tab_body'; export * from './hosts_query_tab_body'; export * from './uncommon_process_query_tab_body'; export * from './alerts_query_tab_body'; diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index 55903a8b47665..08639f48864b3 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -22,10 +22,12 @@ import { MatrixHistogramConfigs, MatrixHistogramOption, } from '../../../common/components/matrix_histogram/types'; -import { eventsStackByOptions } from '../../../hosts/pages/navigation'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; import { useKibana, useUiSetting$ } from '../../../common/lib/kibana'; -import { histogramConfigs } from '../../../hosts/pages/navigation/events_query_tab_body'; +import { + eventsStackByOptions, + histogramConfigs, +} from '../../../common/components/events_tab/events_query_tab_body'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { HostsTableType } from '../../../hosts/store/model'; import { InputsModelId } from '../../../common/store/inputs/constants'; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx index 810525d4f1ca7..0b87165cbe8ac 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -120,6 +120,7 @@ const UserRiskScoreTableComponent: React.FC = ({ dispatch( usersActions.updateTableSorting({ sort: newSort as RiskScoreSortField, + tableType, }) ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/constants.ts b/x-pack/plugins/security_solution/public/users/pages/constants.ts index 95c0e361e82d8..793d7c6164b2d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/constants.ts +++ b/x-pack/plugins/security_solution/public/users/pages/constants.ts @@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model'; export const usersDetailsPagePath = `${USERS_PATH}/:detailName`; -export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk})`; +export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`; -export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies})`; +export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts})`; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx index 966fe067fde88..25ada310b74b7 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Route, Switch } from 'react-router-dom'; import { UsersTableType } from '../../store/model'; @@ -16,6 +16,10 @@ import { scoreIntervalToDateTime } from '../../../common/components/ml/score/sco import { UpdateDateRange } from '../../../common/components/charts/common'; import { Anomaly } from '../../../common/components/ml/types'; import { usersDetailsPagePath } from '../constants'; +import { TimelineId } from '../../../../common/types'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; +import { AlertsView } from '../../../common/components/alerts_viewer'; +import { filterUserExternalAlertData } from './helpers'; export const UsersDetailsTabs = React.memo( ({ @@ -29,6 +33,7 @@ export const UsersDetailsTabs = React.memo( type, setAbsoluteRangeDatePicker, detailName, + pageFilters, }) => { const narrowDateRange = useCallback( (score: Anomaly, interval: string) => { @@ -57,6 +62,14 @@ export const UsersDetailsTabs = React.memo( [setAbsoluteRangeDatePicker] ); + const alertsPageFilters = useMemo( + () => + pageFilters != null + ? [...filterUserExternalAlertData, ...pageFilters] + : filterUserExternalAlertData, + [pageFilters] + ); + const tabProps = { deleteQuery, endDate: to, @@ -76,6 +89,22 @@ export const UsersDetailsTabs = React.memo( + + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts index c96d21d3110e4..daa02df2fb9ca 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts @@ -30,3 +30,35 @@ export const getUsersDetailsPageFilters = (userName: string): Filter[] => [ }, }, ]; + +export const filterUserExternalAlertData: Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'user.name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "user.name"}}],"minimum_should_match": 1}}]}}}', + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx index e68c37d6b4042..36ace6a6b4543 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx @@ -50,6 +50,8 @@ import { useQueryInspector } from '../../../common/components/page/manage_query' import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type'; import { UsersType } from '../../store/model'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; const QUERY_ID = 'UsersDetailsQueryId'; const UsersDetailsComponent: React.FC = ({ @@ -110,6 +112,8 @@ const UsersDetailsComponent: React.FC = ({ skip: selectedPatterns.length === 0, }); + const capabilities = useMlCapabilities(); + useQueryInspector({ setQuery, deleteQuery, refetch, inspect, loading, queryId: QUERY_ID }); return ( @@ -165,7 +169,9 @@ const UsersDetailsComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx index 47bc406876c22..9671bd4ee38d0 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash/fp'; import * as i18n from '../translations'; import { UsersDetailsNavTab } from './types'; import { UsersTableType } from '../../store/model'; @@ -13,13 +14,32 @@ import { USERS_PATH } from '../../../../common/constants'; const getTabsOnUsersDetailsUrl = (userName: string, tabName: UsersTableType) => `${USERS_PATH}/${userName}/${tabName}`; -export const navTabsUsersDetails = (userName: string): UsersDetailsNavTab => { - return { +export const navTabsUsersDetails = ( + userName: string, + hasMlUserPermissions: boolean +): UsersDetailsNavTab => { + const userDetailsNavTabs = { [UsersTableType.anomalies]: { id: UsersTableType.anomalies, name: i18n.NAVIGATION_ANOMALIES_TITLE, href: getTabsOnUsersDetailsUrl(userName, UsersTableType.anomalies), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.alerts), + disabled: false, + }, }; + + return hasMlUserPermissions + ? userDetailsNavTabs + : omit([UsersTableType.anomalies], userDetailsNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/types.ts b/x-pack/plugins/security_solution/public/users/pages/details/types.ts index 69974678bf4d9..1608d4b735b59 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/types.ts @@ -44,7 +44,15 @@ export type UsersDetailsComponentProps = UsersDetailsComponentReduxProps & UsersDetailsComponentDispatchProps & UsersQueryProps; -type KeyUsersDetailsNavTab = UsersTableType.anomalies; +export type KeyUsersDetailsNavTabWithoutMlPermission = UsersTableType.events & + UsersTableType.alerts; + +type KeyUsersDetailsNavTabWithMlPermission = KeyUsersDetailsNavTabWithoutMlPermission & + UsersTableType.anomalies; + +type KeyUsersDetailsNavTab = + | KeyUsersDetailsNavTabWithoutMlPermission + | KeyUsersDetailsNavTabWithMlPermission; export type UsersDetailsNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index eb2820c6d4869..f4bdd7e6caa67 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -24,6 +24,8 @@ const TabNameMappedToI18nKey: Record = { [UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE, [UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE, [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, + [UsersTableType.events]: i18n.NAVIGATION_EVENTS_TITLE, + [UsersTableType.alerts]: i18n.NAVIGATION_ALERTS_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/users/pages/index.tsx b/x-pack/plugins/security_solution/public/users/pages/index.tsx index 0b6b103b78176..f1f4e545ae9fd 100644 --- a/x-pack/plugins/security_solution/public/users/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/index.tsx @@ -12,44 +12,53 @@ import { UsersTableType } from '../store/model'; import { Users } from './users'; import { UsersDetails } from './details'; import { usersDetailsPagePath, usersDetailsTabPath, usersTabPath } from './constants'; +import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; -export const UsersContainer = React.memo(() => ( - - - - +export const UsersContainer = React.memo(() => { + const capabilities = useMlCapabilities(); + const hasMlPermissions = hasMlUserPermissions(capabilities); - } - /> - ( - - )} - /> - ( - - )} - /> - -)); + return ( + + + + + + } + /> + ( + + )} + /> + ( + + )} + /> + + ); +}); UsersContainer.displayName = 'UsersContainer'; diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index 35124d1deddb1..254807eae27cc 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -38,6 +38,18 @@ export const navTabsUsers = ( href: getTabsOnUsersUrl(UsersTableType.risk), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.alerts), + disabled: false, + }, }; if (!hasMlUserPermissions) { diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index 8fa963ef179f2..b5c8b199fda54 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -42,12 +42,11 @@ export const AllUsersQueryTabBody = ({ indexNames, skip: querySkip, startDate, - // TODO Fix me + // TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed // @ts-ignore type, deleteQuery, }); - // TODO Use a different table return ( diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts index f3fd099d78548..d5c49590dad60 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts @@ -10,7 +10,11 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { DocValueFields } from '../../../../../timelines/common'; import { NavTab } from '../../../common/components/navigation/types'; -type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & UsersTableType.risk; +type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & + UsersTableType.risk & + UsersTableType.events & + UsersTableType.alerts; + type KeyUsersNavTabWithMlPermission = KeyUsersNavTabWithoutMlPermission & UsersTableType.anomalies; type KeyUsersNavTab = KeyUsersNavTabWithoutMlPermission | KeyUsersNavTabWithMlPermission; diff --git a/x-pack/plugins/security_solution/public/users/pages/translations.ts b/x-pack/plugins/security_solution/public/users/pages/translations.ts index 7744ef125ffa2..96dcf8d2c8871 100644 --- a/x-pack/plugins/security_solution/public/users/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/users/pages/translations.ts @@ -31,3 +31,17 @@ export const NAVIGATION_RISK_TITLE = i18n.translate( defaultMessage: 'Users by risk', } ); + +export const NAVIGATION_EVENTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.eventsTitle', + { + defaultMessage: 'Events', + } +); + +export const NAVIGATION_ALERTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.alertsTitle', + { + defaultMessage: 'External alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/users.tsx b/x-pack/plugins/security_solution/public/users/pages/users.tsx index bd6cc2d097c46..6acd2ddf32a3c 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users.tsx @@ -162,6 +162,10 @@ const UsersComponent = () => { const capabilities = useMlCapabilities(); const riskyUsersFeatureEnabled = useIsExperimentalFeatureEnabled('riskyUsersEnabled'); + const navTabs = useMemo( + () => navTabsUsers(hasMlUserPermissions(capabilities), riskyUsersFeatureEnabled), + [capabilities, riskyUsersFeatureEnabled] + ); return ( <> @@ -197,9 +201,7 @@ const UsersComponent = () => { - + diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx index 50de49d1e4af1..522ff4c009504 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx @@ -19,6 +19,9 @@ import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_ import { UpdateDateRange } from '../../common/components/charts/common'; import { UserRiskScoreQueryTabBody } from './navigation/user_risk_score_tab_body'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; +import { TimelineId } from '../../../common/types'; +import { AlertsView } from '../../common/components/alerts_viewer'; export const UsersTabs = memo( ({ @@ -83,6 +86,17 @@ export const UsersTabs = memo( + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/store/actions.ts b/x-pack/plugins/security_solution/public/users/store/actions.ts index 262604f68bdf5..b1d83f29da8c8 100644 --- a/x-pack/plugins/security_solution/public/users/store/actions.ts +++ b/x-pack/plugins/security_solution/public/users/store/actions.ts @@ -31,6 +31,7 @@ export const updateTableActivePage = actionCreator<{ export const updateTableSorting = actionCreator<{ sort: RiskScoreSortField; + tableType: usersModel.UsersTableType.risk; }>('UPDATE_USERS_SORTING'); export const updateUserRiskScoreSeverityFilter = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/users/store/model.ts b/x-pack/plugins/security_solution/public/users/store/model.ts index 22630d34d48a8..6e4a3730eca86 100644 --- a/x-pack/plugins/security_solution/public/users/store/model.ts +++ b/x-pack/plugins/security_solution/public/users/store/model.ts @@ -16,6 +16,8 @@ export enum UsersTableType { allUsers = 'allUsers', anomalies = 'anomalies', risk = 'userRisk', + events = 'events', + alerts = 'externalAlerts', } export type AllUsersTables = UsersTableType; @@ -36,10 +38,14 @@ export interface UsersQueries { [UsersTableType.allUsers]: AllUsersQuery; [UsersTableType.anomalies]: null | undefined; [UsersTableType.risk]: UsersRiskScoreQuery; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UserDetailsQueries { [UsersTableType.anomalies]: null | undefined; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UsersPageModel { diff --git a/x-pack/plugins/security_solution/public/users/store/reducer.ts b/x-pack/plugins/security_solution/public/users/store/reducer.ts index 26b2e8a225d5a..4b263eecb8c5a 100644 --- a/x-pack/plugins/security_solution/public/users/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/users/store/reducer.ts @@ -37,11 +37,27 @@ export const initialUsersState: UsersModel = { severitySelection: [], }, [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, details: { queries: { [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, }; @@ -80,14 +96,14 @@ export const usersReducer = reducerWithInitialState(initialUsersState) }, }, })) - .case(updateTableSorting, (state, { sort }) => ({ + .case(updateTableSorting, (state, { sort, tableType }) => ({ ...state, page: { ...state.page, queries: { ...state.page.queries, - [UsersTableType.risk]: { - ...state.page.queries[UsersTableType.risk], + [tableType]: { + ...state.page.queries[tableType], sort, }, }, diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index a6c8ed1b74bff..1e12baf13c2db 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -310,6 +310,8 @@ export type SavedTimelineNote = runtimeTypes.TypeOf Date: Thu, 24 Mar 2022 09:42:46 +0100 Subject: [PATCH 39/66] added missing package field mappings (#128391) --- .../elasticsearch/template/template.test.ts | 49 +++++++++++++++++++ .../epm/elasticsearch/template/template.ts | 5 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 86edf1c5e4064..77ce3779f2319 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -157,6 +157,27 @@ describe('EPM template', () => { expect(mappings).toEqual(longWithIndexFalseMapping); }); + it('tests processing keyword field with doc_values false', () => { + const keywordWithIndexFalseYml = ` +- name: keywordIndexFalse + type: keyword + doc_values: false +`; + const keywordWithIndexFalseMapping = { + properties: { + keywordIndexFalse: { + ignore_above: 1024, + type: 'keyword', + doc_values: false, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithIndexFalseYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithIndexFalseMapping); + }); + it('tests processing text field with multi fields', () => { const textWithMultiFieldsLiteralYml = ` - name: textWithMultiFields @@ -378,6 +399,34 @@ describe('EPM template', () => { expect(mappings).toEqual(keywordWithMultiFieldsMapping); }); + it('tests processing wildcard field with multi fields with match_only_text type', () => { + const wildcardWithMultiFieldsLiteralYml = ` +- name: wildcardWithMultiFields + type: wildcard + multi_fields: + - name: text + type: match_only_text +`; + + const wildcardWithMultiFieldsMapping = { + properties: { + wildcardWithMultiFields: { + ignore_above: 1024, + type: 'wildcard', + fields: { + text: { + type: 'match_only_text', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(wildcardWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(wildcardWithMultiFieldsMapping); + }); + it('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 21c7351b31384..909b593649fcd 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -244,9 +244,8 @@ function generateMultiFields(fields: Fields): MultiFields { multiFields[f.name] = { ...generateKeywordMapping(f), type: f.type }; break; case 'long': - multiFields[f.name] = { type: f.type }; - break; case 'double': + case 'match_only_text': multiFields[f.name] = { type: f.type }; break; } @@ -302,7 +301,7 @@ function getDefaultProperties(field: Field): Properties { if (field.index !== undefined) { properties.index = field.index; } - if (field.doc_values) { + if (field.doc_values !== undefined) { properties.doc_values = field.doc_values; } if (field.copy_to) { From 8f6322596c8765eaf61d6fb99908cdf0004dda01 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Thu, 24 Mar 2022 09:45:19 +0100 Subject: [PATCH 40/66] Add events-first (reverse) search for IM rule (#127428) * WIP * Add tets and refactoring * Add abstraction to run threat im rule * Add per page to search threat indicators * Fix tests and linting * fix tests * Add integrations tests * Fix IP Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../build_threat_mapping_filter.test.ts | 66 ++- .../build_threat_mapping_filter.ts | 15 +- .../threat_mapping/create_event_signal.ts | 155 +++++++ .../threat_mapping/create_threat_signal.ts | 1 + .../threat_mapping/create_threat_signals.ts | 186 +++++--- .../enrich_signal_threat_matches.test.ts | 105 +++++ .../enrich_signal_threat_matches.ts | 59 ++- .../signals/threat_mapping/get_event_count.ts | 54 ++- .../signals/threat_mapping/get_threat_list.ts | 26 +- .../signals/threat_mapping/types.ts | 72 +++ .../signals/threat_mapping/utils.ts | 6 +- .../tests/create_threat_matching.ts | 439 +++++++++++++++++- .../filebeat/threat_intel/data.json | 142 ++++++ 13 files changed, 1238 insertions(+), 88 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index a96eb50af3c50..1b4baaa0607b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -36,6 +36,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1025, + entryKey: 'value', }) ).toThrow('chunk sizes cannot exceed 1024 in size'); }); @@ -44,28 +45,28 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; expect(() => - buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023 }) + buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023, entryKey: 'value' }) ).not.toThrow(); }); test('it should create the correct entries when using the default mocks', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - const filter = buildThreatMappingFilter({ threatMapping, threatList }); + const filter = buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(filter).toEqual(getThreatMappingFilterMock()); }); test('it should not mutate the original threatMapping', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatMapping).toEqual(getThreatMappingMock()); }); test('it should not mutate the original threatListItem', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatList).toEqual(getThreatListSearchResponseMock().hits.hits); }); }); @@ -75,7 +76,7 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping, threatListItem }); + const item = filterThreatMapping({ threatMapping, threatListItem, entryKey: 'value' }); const expected = getFilterThreatMapping(); expect(item).toEqual(expected); }); @@ -84,7 +85,11 @@ describe('build_threat_mapping_filter', () => { const [firstElement] = getThreatMappingMock(); // get only the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping: [firstElement], threatListItem }); + const item = filterThreatMapping({ + threatMapping: [firstElement], + threatListItem, + entryKey: 'value', + }); const [firstElementFilter] = getFilterThreatMapping(); // get only the first element to compare expect(item).toEqual([firstElementFilter]); }); @@ -96,6 +101,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatMapping).toEqual(getThreatMappingMock()); }); @@ -107,6 +113,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatListItem).toEqual(getThreatListSearchResponseMock().hits.hits[0]); }); @@ -142,6 +149,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([]); }); @@ -185,6 +193,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([ { @@ -204,7 +213,11 @@ describe('build_threat_mapping_filter', () => { test('it should return two clauses given a single entry', () => { const [{ entries: threatMappingEntries }] = getThreatMappingMock(); // get the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -219,7 +232,11 @@ describe('build_threat_mapping_filter', () => { test('it should return an empty array given an empty array', () => { const threatListItem = getThreatListItemMock(); - const innerClause = createInnerAndClauses({ threatMappingEntries: [], threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); @@ -234,7 +251,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -263,7 +284,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -290,7 +315,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); }); @@ -299,7 +328,7 @@ describe('build_threat_mapping_filter', () => { test('it should return all clauses given the entries', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); @@ -310,13 +339,17 @@ describe('build_threat_mapping_filter', () => { ...getThreatListSearchResponseMock().hits.hits[0]._source, foo: 'bar', }; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); test('it should return an empty boolean given an empty array', () => { const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping: [], threatListItem }); + const innerClause = createAndOrClauses({ + threatMapping: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -325,6 +358,7 @@ describe('build_threat_mapping_filter', () => { const innerClause = createAndOrClauses({ threatMapping, threatListItem: getThreatListItemMock({ _source: {}, fields: {} }), + entryKey: 'value', }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -338,6 +372,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, @@ -352,6 +387,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -365,6 +401,7 @@ describe('build_threat_mapping_filter', () => { threatMapping: [], threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -399,6 +436,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index dfc66f7c5222e..82b6c5a6c523f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -25,6 +25,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey = 'value', }: BuildThreatMappingFilterOptions): Filter => { const computedChunkSize = chunkSize ?? MAX_CHUNK_SIZE; if (computedChunkSize > 1024) { @@ -34,6 +35,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize: computedChunkSize, + entryKey, }); const filterChunk: Filter = { meta: { @@ -52,11 +54,12 @@ export const buildThreatMappingFilter = ({ export const filterThreatMapping = ({ threatMapping, threatListItem, + entryKey, }: FilterThreatMappingOptions): ThreatMapping => threatMapping .map((threatMap) => { const atLeastOneItemMissingInThreatList = threatMap.entries.some((entry) => { - const itemValue = get(entry.value, threatListItem.fields); + const itemValue = get(entry[entryKey], threatListItem.fields); return itemValue == null || itemValue.length !== 1; }); if (atLeastOneItemMissingInThreatList) { @@ -70,9 +73,10 @@ export const filterThreatMapping = ({ export const createInnerAndClauses = ({ threatMappingEntries, threatListItem, + entryKey, }: CreateInnerAndClausesOptions): BooleanFilter[] => { return threatMappingEntries.reduce((accum, threatMappingEntry) => { - const value = get(threatMappingEntry.value, threatListItem.fields); + const value = get(threatMappingEntry[entryKey], threatListItem.fields); if (value != null && value.length === 1) { // These values could be potentially 10k+ large so mutating the array intentionally accum.push({ @@ -80,7 +84,7 @@ export const createInnerAndClauses = ({ should: [ { match: { - [threatMappingEntry.field]: { + [threatMappingEntry[entryKey === 'field' ? 'value' : 'field']]: { query: value[0], _name: encodeThreatMatchNamedQuery({ id: threatListItem._id, @@ -103,11 +107,13 @@ export const createInnerAndClauses = ({ export const createAndOrClauses = ({ threatMapping, threatListItem, + entryKey, }: CreateAndOrClausesOptions): BooleanFilter => { const should = threatMapping.reduce((accum, threatMap) => { const innerAndClauses = createInnerAndClauses({ threatMappingEntries: threatMap.entries, threatListItem, + entryKey, }); if (innerAndClauses.length !== 0) { // These values could be potentially 10k+ large so mutating the array intentionally @@ -124,15 +130,18 @@ export const buildEntriesMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey, }: BuildEntriesMappingFilterOptions): BooleanFilter => { const combinedShould = threatList.reduce((accum, threatListSearchItem) => { const filteredEntries = filterThreatMapping({ threatMapping, threatListItem: threatListSearchItem, + entryKey, }); const queryWithAndOrClause = createAndOrClauses({ threatMapping: filteredEntries, threatListItem: threatListSearchItem, + entryKey, }); if (queryWithAndOrClause.bool.should.length !== 0) { // These values can be 10k+ large, so using a push here for performance diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts new file mode 100644 index 0000000000000..c1beb55e90a85 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildThreatMappingFilter } from './build_threat_mapping_filter'; +import { getFilter } from '../get_filter'; +import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; +import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters'; +import { CreateEventSignalOptions } from './types'; +import { SearchAfterAndBulkCreateReturnType, SignalSearchResponse } from '../types'; +import { getAllThreatListHits } from './get_threat_list'; +import { + enrichSignalThreatMatches, + getSignalMatchesFromThreatList, +} from './enrich_signal_threat_matches'; + +export const createEventSignal = async ({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult, + currentEventList, + eventsTelemetry, + exceptionItems, + filters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, +}: CreateEventSignalOptions): Promise => { + const threatFilter = buildThreatMappingFilter({ + threatMapping, + threatList: currentEventList, + entryKey: 'field', + }); + + if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { + // empty event list and we do not want to return everything as being + // a hit so opt to return the existing result. + logger.debug( + buildRuleMessage( + 'Indicator items are empty after filtering for missing data, returning without attempting a match' + ) + ); + return currentResult; + } else { + const threatListHits = await getAllThreatListHits({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: [...threatFilters, threatFilter], + query: threatQuery, + language: threatLanguage, + index: threatIndex, + logger, + buildRuleMessage, + threatListConfig: { + _source: [`${threatIndicatorPath}.*`, 'threat.feed.*'], + fields: undefined, + }, + perPage, + }); + + const signalMatches = getSignalMatchesFromThreatList(threatListHits); + + const ids = signalMatches.map((item) => item.signalId); + + const indexFilter = { + query: { + bool: { + filter: { + ids: { values: ids }, + }, + }, + }, + }; + + const esFilter = await getFilter({ + type, + filters: [...filters, indexFilter], + language, + query, + savedId, + services, + index: inputIndex, + lists: exceptionItems, + }); + + logger.debug( + buildRuleMessage( + `${ids?.length} matched signals found from ${threatListHits.length} indicators` + ) + ); + + const threatEnrichment = (signals: SignalSearchResponse): Promise => + enrichSignalThreatMatches( + signals, + () => Promise.resolve(threatListHits), + threatIndicatorPath, + signalMatches + ); + + const result = await searchAfterAndBulkCreate({ + buildReasonMessage: buildReasonMessageForThreatMatchAlert, + buildRuleMessage, + bulkCreate, + completeRule, + enrichment: threatEnrichment, + eventsTelemetry, + exceptionsList: exceptionItems, + filter: esFilter, + id: alertId, + inputIndexPattern: inputIndex, + listClient, + logger, + pageSize: searchAfterSize, + services, + signalsIndex: outputIndex, + sortOrder: 'desc', + trackTotalHits: false, + tuple, + wrapHits, + }); + + logger.debug( + buildRuleMessage( + `${ + threatFilter.query?.bool.should.length + } items have completed match checks and the total times to search were ${ + result.searchAfterTimes.length !== 0 ? result.searchAfterTimes : '(unknown) ' + }ms` + ) + ); + return result; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index bf72a13ba0450..220bebbaa4d21 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -41,6 +41,7 @@ export const createThreatSignal = async ({ const threatFilter = buildThreatMappingFilter({ threatMapping, threatList: currentThreatList, + entryKey: 'value', }); if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 292a5f897885f..eecc55a67ad52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -7,13 +7,17 @@ import chunk from 'lodash/fp/chunk'; import { getThreatList, getThreatListCount } from './get_threat_list'; - -import { CreateThreatSignalsOptions } from './types'; +import { + CreateThreatSignalsOptions, + CreateSignalInterface, + GetDocumentListInterface, +} from './types'; import { createThreatSignal } from './create_threat_signal'; +import { createEventSignal } from './create_event_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; import { buildExecutionIntervalValidator, combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; -import { getEventCount } from './get_event_count'; +import { getEventCount, getEventList } from './get_event_count'; import { getMappingFilters } from './get_mapping_filters'; export const createThreatSignals = async ({ @@ -85,7 +89,7 @@ export const createThreatSignals = async ({ return results; } - let threatListCount = await getThreatListCount({ + const threatListCount = await getThreatListCount({ esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters: allThreatFilters, @@ -101,20 +105,6 @@ export const createThreatSignals = async ({ _source: false, }; - let threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - threatFilters: allThreatFilters, - query: threatQuery, - language: threatLanguage, - index: threatIndex, - searchAfter: undefined, - logger, - buildRuleMessage, - perPage, - threatListConfig, - }); - const threatEnrichment = buildThreatEnrichment({ buildRuleMessage, exceptionItems, @@ -127,12 +117,124 @@ export const createThreatSignals = async ({ threatQuery, }); - while (threatList.hits.hits.length !== 0) { - verifyExecutionCanProceed(); - const chunks = chunk(itemsPerSearch, threatList.hits.hits); - logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); - const concurrentSearchesPerformed = chunks.map>( - (slicedChunk) => + const createSignals = async ({ + getDocumentList, + createSignal, + totalDocumentCount, + }: { + getDocumentList: GetDocumentListInterface; + createSignal: CreateSignalInterface; + totalDocumentCount: number; + }) => { + let list = await getDocumentList({ searchAfter: undefined }); + let documentCount = totalDocumentCount; + + while (list.hits.hits.length !== 0) { + verifyExecutionCanProceed(); + const chunks = chunk(itemsPerSearch, list.hits.hits); + logger.debug( + buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`) + ); + const concurrentSearchesPerformed = + chunks.map>(createSignal); + const searchesPerformed = await Promise.all(concurrentSearchesPerformed); + results = combineConcurrentResults(results, searchesPerformed); + documentCount -= list.hits.hits.length; + logger.debug( + buildRuleMessage( + `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, + `search times of ${results.searchAfterTimes}ms,`, + `bulk create times ${results.bulkCreateTimes}ms,`, + `all successes are ${results.success}` + ) + ); + if (results.createdSignalsCount >= params.maxSignals) { + logger.debug( + buildRuleMessage( + `Indicator match has reached its max signals count ${params.maxSignals}. Additional documents not checked are ${documentCount}` + ) + ); + break; + } + logger.debug(buildRuleMessage(`Documents items left to check are ${documentCount}`)); + + list = await getDocumentList({ + searchAfter: list.hits.hits[list.hits.hits.length - 1].sort, + }); + } + }; + + if (eventCount < threatListCount) { + await createSignals({ + totalDocumentCount: eventCount, + getDocumentList: async ({ searchAfter }) => + getEventList({ + services, + exceptionItems, + filters: allEventFilters, + query, + language, + index: inputIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + tuple, + }), + + createSignal: (slicedChunk) => + createEventSignal({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult: results, + currentEventList: slicedChunk, + eventsTelemetry, + exceptionItems, + filters: allEventFilters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatEnrichment, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters: allThreatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, + }), + }); + } else { + await createSignals({ + totalDocumentCount: threatListCount, + getDocumentList: async ({ searchAfter }) => + getThreatList({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: allThreatFilters, + query: threatQuery, + language: threatLanguage, + index: threatIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + threatListConfig, + }), + + createSignal: (slicedChunk) => createThreatSignal({ alertId, buildRuleMessage, @@ -157,41 +259,7 @@ export const createThreatSignals = async ({ tuple, type, wrapHits, - }) - ); - const searchesPerformed = await Promise.all(concurrentSearchesPerformed); - results = combineConcurrentResults(results, searchesPerformed); - threatListCount -= threatList.hits.hits.length; - logger.debug( - buildRuleMessage( - `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, - `search times of ${results.searchAfterTimes}ms,`, - `bulk create times ${results.bulkCreateTimes}ms,`, - `all successes are ${results.success}` - ) - ); - if (results.createdSignalsCount >= params.maxSignals) { - logger.debug( - buildRuleMessage( - `Indicator match has reached its max signals count ${params.maxSignals}. Additional indicator items not checked are ${threatListCount}` - ) - ); - break; - } - logger.debug(buildRuleMessage(`Indicator items left to check are ${threatListCount}`)); - - threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - query: threatQuery, - language: threatLanguage, - threatFilters: allThreatFilters, - index: threatIndex, - searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, - buildRuleMessage, - logger, - perPage, - threatListConfig, + }), }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index 4e249711bb890..66e44e5796eb6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -14,6 +14,7 @@ import { buildEnrichments, enrichSignalThreatMatches, groupAndMergeSignalMatches, + getSignalMatchesFromThreatList, } from './enrich_signal_threat_matches'; import { getNamedQueryMock, @@ -793,3 +794,107 @@ describe('enrichSignalThreatMatches', () => { ]); }); }); + +describe('getSignalMatchesFromThreatList', () => { + it('return empty array if there no threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList(); + expect(signalMatches).toEqual([]); + }); + + it("return empty array if threat indicators doesn't have matched query", () => { + const signalMatches = getSignalMatchesFromThreatList([getThreatListItemMock()]); + expect(signalMatches).toEqual([]); + }); + + it('return signal mathces from threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId', + matched_queries: [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId1', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId2', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ], + }), + ]); + + const queries = [ + { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }, + ]; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId1', + queries, + }, + { + signalId: 'signalId2', + queries, + }, + ]); + }); + + it('merge signal mathces if different threat indicators matched the same signal', () => { + const matchedQuery = [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ]; + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId1', + matched_queries: matchedQuery, + }), + getThreatListItemMock({ + _id: 'threatId2', + matched_queries: matchedQuery, + }), + ]); + + const query = { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId', + queries: [ + { + ...query, + id: 'threatId1', + }, + { + ...query, + id: 'threatId2', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 8c7b0b89a0cb7..c1fb88176fd4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -14,9 +14,43 @@ import type { ThreatEnrichment, ThreatListItem, ThreatMatchNamedQuery, + SignalMatch, } from './types'; import { extractNamedQueries } from './utils'; +export const getSignalMatchesFromThreatList = ( + threatList: ThreatListItem[] = [] +): SignalMatch[] => { + const signalMap: { [key: string]: ThreatMatchNamedQuery[] } = {}; + + threatList.forEach((threatHit) => + extractNamedQueries(threatHit).forEach((item) => { + const signalId = item.id; + if (!signalId) { + return; + } + + if (!signalMap[signalId]) { + signalMap[signalId] = []; + } + + signalMap[signalId].push({ + id: threatHit._id, + index: threatHit._index, + field: item.field, + value: item.value, + }); + }) + ); + + const signalMatches = Object.entries(signalMap).map(([key, value]) => ({ + signalId: key, + queries: value, + })); + + return signalMatches; +}; + const getSignalId = (signal: SignalSourceHit): string => signal._id; export const groupAndMergeSignalMatches = (signalHits: SignalSourceHit[]): SignalSourceHit[] => { @@ -77,7 +111,8 @@ export const buildEnrichments = ({ export const enrichSignalThreatMatches = async ( signals: SignalSearchResponse, getMatchedThreats: GetMatchedThreats, - indicatorPath: string + indicatorPath: string, + signalMatchesArg?: SignalMatch[] ): Promise => { const signalHits = signals.hits.hits; if (signalHits.length === 0) { @@ -85,13 +120,27 @@ export const enrichSignalThreatMatches = async ( } const uniqueHits = groupAndMergeSignalMatches(signalHits); - const signalMatches = uniqueHits.map((signalHit) => extractNamedQueries(signalHit)); - const matchedThreatIds = [...new Set(signalMatches.flat().map(({ id }) => id))]; + const signalMatches: SignalMatch[] = signalMatchesArg + ? signalMatchesArg + : uniqueHits.map((signalHit) => ({ + signalId: signalHit._id, + queries: extractNamedQueries(signalHit), + })); + + const matchedThreatIds = [ + ...new Set( + signalMatches + .map((signalMatch) => signalMatch.queries) + .flat() + .map(({ id }) => id) + ), + ]; const matchedThreats = await getMatchedThreats(matchedThreatIds); - const enrichmentsWithoutAtomic = signalMatches.map((queries) => + + const enrichmentsWithoutAtomic = signalMatches.map((signalMatch) => buildEnrichments({ indicatorPath, - queries, + queries: signalMatch.queries, threats: matchedThreats, }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts index 28a994280abed..2c6d3bd8cc38d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts @@ -5,10 +5,62 @@ * 2.0. */ -import { EventCountOptions } from './types'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { EventCountOptions, EventsOptions, EventDoc } from './types'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; +import { singleSearchAfter } from '../../signals/single_search_after'; import { buildEventsSearchQuery } from '../build_events_query'; +export const MAX_PER_PAGE = 9000; + +export const getEventList = async ({ + services, + query, + language, + index, + perPage, + searchAfter, + exceptionItems, + filters, + buildRuleMessage, + logger, + tuple, + timestampOverride, +}: EventsOptions): Promise> => { + const calculatedPerPage = perPage ?? MAX_PER_PAGE; + if (calculatedPerPage > 10000) { + throw new TypeError('perPage cannot exceed the size of 10000'); + } + + logger.debug( + buildRuleMessage( + `Querying the events items from the index: "${index}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + ) + ); + + const filter = getQueryFilter(query, language ?? 'kuery', filters, index, exceptionItems); + + const { searchResult } = await singleSearchAfter({ + buildRuleMessage, + searchAfterSortIds: searchAfter, + index, + from: tuple.from.toISOString(), + to: tuple.to.toISOString(), + services, + logger, + filter, + pageSize: Math.ceil(Math.min(tuple.maxSignals, calculatedPerPage)), + timestampOverride, + sortOrder: 'desc', + trackTotalHits: false, + }); + + logger.debug( + buildRuleMessage(`Retrieved events items of size: ${searchResult.hits.hits.length}`) + ); + return searchResult; +}; + export const getEventCount = async ({ esClient, query, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index f31c1fbfdaec3..9f2fcef2f6883 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -7,7 +7,12 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; -import { GetThreatListOptions, ThreatListCountOptions, ThreatListDoc } from './types'; +import { + GetThreatListOptions, + ThreatListCountOptions, + ThreatListDoc, + ThreatListItem, +} from './types'; /** * This should not exceed 10000 (10k) @@ -89,3 +94,22 @@ export const getThreatListCount = async ({ }); return response.count; }; + +export const getAllThreatListHits = async ( + params: Omit +): Promise => { + let allThreatListHits: ThreatListItem[] = []; + let threatList = await getThreatList({ ...params, searchAfter: undefined }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + + while (threatList.hits.hits.length !== 0) { + threatList = await getThreatList({ + ...params, + searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, + }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + } + return allThreatListHits; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 45fa47288a958..8beabe072c13f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -94,31 +94,70 @@ export interface CreateThreatSignalOptions { wrapHits: WrapHits; } +export interface CreateEventSignalOptions { + alertId: string; + buildRuleMessage: BuildRuleMessage; + bulkCreate: BulkCreate; + completeRule: CompleteRule; + currentResult: SearchAfterAndBulkCreateReturnType; + currentEventList: EventItem[]; + eventsTelemetry: ITelemetryEventsSender | undefined; + exceptionItems: ExceptionListItemSchema[]; + filters: unknown[]; + inputIndex: string[]; + language: LanguageOrUndefined; + listClient: ListClient; + logger: Logger; + outputIndex: string; + query: string; + savedId: string | undefined; + searchAfterSize: number; + services: AlertServices; + threatEnrichment: SignalsEnrichment; + tuple: RuleRangeTuple; + type: Type; + wrapHits: WrapHits; + threatFilters: unknown[]; + threatIndex: ThreatIndex; + threatIndicatorPath: ThreatIndicatorPath; + threatLanguage: ThreatLanguageOrUndefined; + threatMapping: ThreatMapping; + threatQuery: ThreatQuery; + threatListConfig: ThreatListConfig; + perPage?: number; +} + +type EntryKey = 'field' | 'value'; export interface BuildThreatMappingFilterOptions { chunkSize?: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface FilterThreatMappingOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface CreateInnerAndClausesOptions { threatListItem: ThreatListItem; threatMappingEntries: ThreatMappingEntries; + entryKey: EntryKey; } export interface CreateAndOrClausesOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface BuildEntriesMappingFilterOptions { chunkSize: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface SplitShouldClausesOptions { @@ -199,6 +238,26 @@ export interface BuildThreatEnrichmentOptions { threatQuery: ThreatQuery; } +export interface EventsOptions { + services: AlertServices; + query: string; + buildRuleMessage: BuildRuleMessage; + language: ThreatLanguageOrUndefined; + exceptionItems: ExceptionListItemSchema[]; + index: string[]; + searchAfter: estypes.SortResults | undefined; + perPage?: number; + logger: Logger; + filters: unknown[]; + timestampOverride?: string; + tuple: RuleRangeTuple; +} + +export interface EventDoc { + [key: string]: unknown; +} + +export type EventItem = estypes.SearchHit; export interface EventCountOptions { esClient: ElasticsearchClient; exceptionItems: ExceptionListItemSchema[]; @@ -209,3 +268,16 @@ export interface EventCountOptions { tuple: RuleRangeTuple; timestampOverride?: string; } + +export interface SignalMatch { + signalId: string; + queries: ThreatMatchNamedQuery[]; +} + +export type GetDocumentListInterface = (params: { + searchAfter: estypes.SortResults | undefined; +}) => Promise>; + +export type CreateSignalInterface = ( + params: EventItem[] | ThreatListItem[] +) => Promise; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 99f6609faec91..2918bffec3631 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; import { parseInterval } from '../utils'; -import { ThreatMatchNamedQuery } from './types'; +import { ThreatMatchNamedQuery, ThreatListItem } from './types'; /** * Given two timers this will take the max of each and add them to each other and return that addition. @@ -147,7 +147,9 @@ export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQu return query; }; -export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => +export const extractNamedQueries = ( + hit: SignalSourceHit | ThreatListItem +): ThreatMatchNamedQuery[] => hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; export const buildExecutionIntervalValidator: (interval: string) => () => void = (interval) => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index f5c066f61db1c..278018796d10a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -493,7 +493,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('indicator enrichment', () => { + describe('indicator enrichment: threat-first search', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); }); @@ -513,7 +513,440 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', + query: '*:*', // narrow events down to 2 with a destination.ip + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.domain: 159.89.119.67', // narrow things down to indicators with a domain + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat); + expect(threats).to.eql([ + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + ]); + }); + + it('enriches signals with multiple indicators if several matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('adds a single indicator that matched multiple fields', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.port: 57324 or threat.indicator.ip:45.115.45.3', // narrow our query to a single indicator + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + // We do not merge matched indicators during enrichment, so in + // certain circumstances a given indicator document could appear + // multiple times in an enriched alert (albeit with different + // threat.indicator.matched data). That's the case with the + // first and third indicators matched, here. + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('generates multiple signals with multiple matches', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + threat_language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', + threat_query: '*:*', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threats[0].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + + assertContains(threats[1].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + }); + + describe('indicator enrichment: event-first search', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + it('enriches signals with the single indicator that matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'destination.ip:159.89.119.67', threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module @@ -797,7 +1230,7 @@ export default ({ getService }: FtrProviderContext) => { threat_language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', // narrow our query to a single record that matches two indicators + query: '(source.port:57324 and source.ip:45.115.45.3) or destination.ip:159.89.119.67', // narrow our query to a single record that matches two indicators threat_indicator_path: 'threat.indicator', threat_query: '*:*', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json index f426ffae33e1c..80ccf200301c7 100644 --- a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json @@ -274,3 +274,145 @@ } } } + +{ + "type": "doc", + "value": { + "id": "978766", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "8.8.8.8", + "port": 777, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978767", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "9.9.9.9", + "port": 123, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} \ No newline at end of file From 55d747617202d1b52967ec1f4e907f0f850b6c28 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 24 Mar 2022 10:51:02 +0100 Subject: [PATCH 41/66] [Actionable Observability] rules page read permissions & snoozed status & no data screen (#128108) * users with read permissions can not edit/delete/create rules * users with read permissions can not change the status * clean up unused code * localization for change status aria label * remove console log * add muted status * rename to snoozed * remove unused imports * rename snoozed to snoozed permanently * localize statuses * implement no data and no permission screen * fix prompt filenames * fix i18n error * change permanently to indefinitely * do not show noData screen when filters are applied and don't match any results * add centered spinner on initial load * move currrent functionality from triggers_actions_ui related to pagination to the use_fetch_rules hook * disable status column if license is not enabled Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/hooks/use_fetch_rules.ts | 24 +- .../components/center_justified_spinner.tsx | 25 ++ .../public/pages/rules/components/name.tsx | 17 +- .../components/prompts/no_data_prompt.tsx | 69 +++++ .../prompts/no_permission_prompt.tsx | 44 ++++ .../public/pages/rules/components/status.tsx | 23 +- .../pages/rules/components/status_context.tsx | 31 ++- .../public/pages/rules/config.ts | 16 +- .../public/pages/rules/index.tsx | 244 +++++++++++------- .../public/pages/rules/translations.ts | 28 ++ .../observability/public/pages/rules/types.ts | 6 + .../triggers_actions_ui/public/index.ts | 1 + 12 files changed, 400 insertions(+), 128 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx create mode 100644 x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx create mode 100644 x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index b81046df99d28..53b2f68821710 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -6,6 +6,7 @@ */ import { useEffect, useState, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { loadRules, Rule } from '../../../triggers_actions_ui/public'; import { RULES_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps } from '../pages/rules/types'; @@ -19,7 +20,13 @@ interface RuleState { totalItemCount: number; } -export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }: FetchRulesProps) { +export function useFetchRules({ + searchText, + ruleLastResponseFilter, + setPage, + page, + sort, +}: FetchRulesProps) { const { http } = useKibana().services; const [rulesState, setRulesState] = useState({ @@ -29,6 +36,9 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } totalItemCount: 0, }); + const [noData, setNoData] = useState(true); + const [initialLoad, setInitialLoad] = useState(true); + const fetchRules = useCallback(async () => { setRulesState((oldState) => ({ ...oldState, isLoading: true })); @@ -47,10 +57,18 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } data: response.data, totalItemCount: response.total, })); + + if (!response.data?.length && page.index > 0) { + setPage({ ...page, index: 0 }); + } + const isFilterApplied = !(isEmpty(searchText) && isEmpty(ruleLastResponseFilter)); + + setNoData(response.data.length === 0 && !isFilterApplied); } catch (_e) { setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR })); } - }, [http, page, searchText, ruleLastResponseFilter, sort]); + setInitialLoad(false); + }, [http, page, setPage, searchText, ruleLastResponseFilter, sort]); useEffect(() => { fetchRules(); }, [fetchRules]); @@ -59,5 +77,7 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } rulesState, reload: fetchRules, setRulesState, + noData, + initialLoad, }; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx new file mode 100644 index 0000000000000..867d530eb4e2f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner'; + +interface Props { + size?: EuiLoadingSpinnerSize; +} + +export function CenterJustifiedSpinner({ size }: Props) { + return ( + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 2b1f831256910..cbde68ea27eb4 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; @@ -34,17 +33,5 @@ export function Name({ name, rule }: RuleNameProps) { ); - return ( - <> - {link} - {rule.enabled && rule.muteAll && ( - - - - )} - - ); + return <>{link}; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx new file mode 100644 index 0000000000000..b9c0e24160004 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiButton, EuiEmptyPrompt, EuiLink, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; + +export function NoDataPrompt({ + onCTAClicked, + documentationLink, +}: { + onCTAClicked: () => void; + documentationLink: string; +}) { + return ( + + + + + } + body={ +

    + +

    + } + actions={[ + + + , + + + Documentation + + , + ]} + /> +
    + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx new file mode 100644 index 0000000000000..edfe1c6840d8b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; + +export function NoPermissionPrompt() { + return ( + + + + + } + body={ +

    + +

    + } + /> +
    + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx index abc2dc8bfa492..612d6f8f30bdd 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx @@ -5,19 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiBadge } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { StatusProps } from '../types'; import { statusMap } from '../config'; +import { RULES_CHANGE_STATUS } from '../translations'; -export function Status({ type, onClick }: StatusProps) { +export function Status({ type, disabled, onClick }: StatusProps) { + const props = useMemo( + () => ({ + color: statusMap[type].color, + ...(!disabled ? { onClick } : { onClick: noop }), + ...(!disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), + }), + [disabled, onClick, type] + ); return ( {statusMap[type].label} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx index 49761d7c43154..c7bd29d85b17a 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx @@ -18,19 +18,26 @@ import { statusMap } from '../config'; export function StatusContext({ item, + disabled = false, onStatusChanged, enableRule, disableRule, muteRule, + unMuteRule, }: StatusContextProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); - const currentStatus = item.enabled ? RuleStatus.enabled : RuleStatus.disabled; + let currentStatus: RuleStatus; + if (item.enabled) { + currentStatus = item.muteAll ? RuleStatus.snoozed : RuleStatus.enabled; + } else { + currentStatus = RuleStatus.disabled; + } const popOverButton = useMemo( - () => , - [currentStatus, togglePopover] + () => , + [disabled, currentStatus, togglePopover] ); const onContextMenuItemClick = useCallback( @@ -41,15 +48,30 @@ export function StatusContext({ if (status === RuleStatus.enabled) { await enableRule({ ...item, enabled: true }); + if (item.muteAll) { + await unMuteRule({ ...item, muteAll: false }); + } } else if (status === RuleStatus.disabled) { await disableRule({ ...item, enabled: false }); + } else if (status === RuleStatus.snoozed) { + await muteRule({ ...item, muteAll: true }); } setIsUpdating(false); onStatusChanged(status); } }, - [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged] + [ + item, + togglePopover, + enableRule, + disableRule, + muteRule, + unMuteRule, + currentStatus, + onStatusChanged, + ] ); + const panelItems = useMemo( () => Object.values(RuleStatus).map((status: RuleStatus) => ( @@ -57,6 +79,7 @@ export function StatusContext({ icon={status === currentStatus ? 'check' : 'empty'} key={status} onClick={() => onContextMenuItemClick(status)} + disabled={status === RuleStatus.snoozed && currentStatus === RuleStatus.disabled} > {statusMap[status].label} diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index afff097776e19..736f538ee7b21 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -13,6 +13,9 @@ import { RULE_STATUS_PENDING, RULE_STATUS_UNKNOWN, RULE_STATUS_WARNING, + RULE_STATUS_ENABLED, + RULE_STATUS_DISABLED, + RULE_STATUS_SNOOZED_INDEFINITELY, } from './translations'; import { AlertExecutionStatuses } from '../../../../alerting/common'; import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; @@ -20,11 +23,15 @@ import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/p export const statusMap: Status = { [RuleStatus.enabled]: { color: 'primary', - label: 'Enabled', + label: RULE_STATUS_ENABLED, }, [RuleStatus.disabled]: { color: 'default', - label: 'Disabled', + label: RULE_STATUS_DISABLED, + }, + [RuleStatus.snoozed]: { + color: 'warning', + label: RULE_STATUS_SNOOZED_INDEFINITELY, }, }; @@ -93,3 +100,8 @@ export function convertRulesToTableItems( enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, })); } + +type Capabilities = Record; + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index 8c44fa90fb3d1..21664ca63507d 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -32,6 +32,9 @@ import { ExecutionStatus } from './components/execution_status'; import { LastRun } from './components/last_run'; import { EditRuleFlyout } from './components/edit_rule_flyout'; import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; +import { NoDataPrompt } from './components/prompts/no_data_prompt'; +import { NoPermissionPrompt } from './components/prompts/no_permission_prompt'; +import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { deleteRules, RuleTableItem, @@ -39,6 +42,7 @@ import { disableRule, muteRule, useLoadRuleTypes, + unmuteRule, } from '../../../../triggers_actions_ui/public'; import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common'; import { Pagination } from './types'; @@ -46,6 +50,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE, convertRulesToTableItems, OBSERVABILITY_SOLUTIONS, + hasExecuteActionsCapability, } from './config'; import { LAST_RESPONSE_COLUMN_TITLE, @@ -73,9 +78,12 @@ export function RulesPage() { http, docLinks, triggersActionsUi, + application: { capabilities }, notifications: { toasts }, } = useKibana().services; - + const documentationLink = docLinks.links.alerting.guide; + const ruleTypeRegistry = triggersActionsUi.ruleTypeRegistry; + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); const [sort, setSort] = useState['sort']>({ field: 'name', @@ -90,6 +98,9 @@ export function RulesPage() { const [rulesToDelete, setRulesToDelete] = useState([]); const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false); + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + const onRuleEdit = (ruleItem: RuleTableItem) => { setCurrentRuleToEdit(ruleItem); }; @@ -102,14 +113,22 @@ export function RulesPage() { setRefreshInterval(refreshIntervalChanged); }; - const { rulesState, setRulesState, reload } = useFetchRules({ + const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({ searchText, ruleLastResponseFilter, page, + setPage, sort, }); const { data: rules, totalItemCount, error } = rulesState; - const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS }); + const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + const authorizedRuleTypes = [...ruleTypes.values()]; + + const authorizedToCreateAnyRules = authorizedRuleTypes.some( + (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all + ); useEffect(() => { const interval = setInterval(() => { @@ -161,11 +180,13 @@ export function RulesPage() { render: (_enabled: boolean, item: RuleTableItem) => { return ( reload()} enableRule={async () => await enableRule({ http, id: item.id })} disableRule={async () => await disableRule({ http, id: item.id })} muteRule={async () => await muteRule({ http, id: item.id })} + unMuteRule={async () => await unmuteRule({ http, id: item.id })} /> ); }, @@ -180,6 +201,9 @@ export function RulesPage() { { + if (noData && !rulesState.isLoading) { + return authorizedToCreateAnyRules ? ( + setCreateRuleFlyoutVisibility(true)} + /> + ) : ( + + ); + } + if (initialLoad) { + return ; + } + return ( + <> + + + { + setInputText(e.target.value); + if (e.target.value === '') { + setSearchText(e.target.value); + } + }} + onKeyUp={(e) => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={SEARCH_PLACEHOLDER} + /> + + + setRuleLastResponseFilter(ids)} + /> + + + + + + , + + + + + + + + + + + + + + + + setPage(index)} + sort={sort} + onSortChange={(changedSort) => { + setSort(changedSort); + }} + /> + + + + ); + }; + return ( ), rightSideItems: [ - setCreateRuleFlyoutVisibility(true)} - > - - , + authorizedToCreateAnyRules && ( + setCreateRuleFlyoutVisibility(true)} + > + + + ), - - - { - setInputText(e.target.value); - if (e.target.value === '') { - setSearchText(e.target.value); - } - }} - onKeyUp={(e) => { - if (e.keyCode === ENTER_KEY) { - setSearchText(inputText); - } - }} - placeholder={SEARCH_PLACEHOLDER} - /> - - - setRuleLastResponseFilter(ids)} - /> - - - - - - , - - - - - - - - - - - - - - - - setPage(index)} - sort={sort} - onSortChange={(changedSort) => { - setSort(changedSort); - }} - /> - - + {getRulesTable()} {error && toasts.addDanger({ title: error, diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index b72d03bf8e566..36f8ff62f1a4c 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -53,6 +53,27 @@ export const RULE_STATUS_WARNING = i18n.translate( } ); +export const RULE_STATUS_ENABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusEnabled', + { + defaultMessage: 'Enabled', + } +); + +export const RULE_STATUS_DISABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusDisabled', + { + defaultMessage: 'Disabled', + } +); + +export const RULE_STATUS_SNOOZED_INDEFINITELY = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusSnoozedIndefinitely', + { + defaultMessage: 'Snoozed indefinitely', + } +); + export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', { @@ -144,6 +165,13 @@ export const SEARCH_PLACEHOLDER = i18n.translate( { defaultMessage: 'Search' } ); +export const RULES_CHANGE_STATUS = i18n.translate( + 'xpack.observability.rules.rulesTable.changeStatusAriaLabel', + { + defaultMessage: 'Change status', + } +); + export const confirmModalText = ( numIdsToDelete: number, singleTitle: string, diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts index 23443890ad8fa..1a15cf3d9cef2 100644 --- a/x-pack/plugins/observability/public/pages/rules/types.ts +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -4,17 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Dispatch, SetStateAction } from 'react'; import { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui'; import { AlertExecutionStatus } from '../../../../alerting/common'; import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public'; export interface StatusProps { type: RuleStatus; + disabled: boolean; onClick: () => void; } export enum RuleStatus { enabled = 'enabled', disabled = 'disabled', + snoozed = 'snoozed', } export type Status = Record< @@ -27,10 +30,12 @@ export type Status = Record< export interface StatusContextProps { item: RuleTableItem; + disabled: boolean; onStatusChanged: (status: RuleStatus) => void; enableRule: (rule: Rule) => Promise; disableRule: (rule: Rule) => Promise; muteRule: (rule: Rule) => Promise; + unMuteRule: (rule: Rule) => Promise; } export interface StatusFilterProps { @@ -65,6 +70,7 @@ export interface FetchRulesProps { searchText: string | undefined; ruleLastResponseFilter: string[]; page: Pagination; + setPage: Dispatch>; sort: EuiTableSortingType['sort']; } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index eb346e43cfbc9..b1ef489bfef70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ export { deleteRules } from './application/lib/rule_api/delete'; export { enableRule } from './application/lib/rule_api/enable'; export { disableRule } from './application/lib/rule_api/disable'; export { muteRule } from './application/lib/rule_api/mute'; +export { unmuteRule } from './application/lib/rule_api/unmute'; export { loadRuleAggregations } from './application/lib/rule_api/aggregate'; export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; From 01d2e2e6b9e4995c4fa8160f6d458daab2a9709a Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Thu, 24 Mar 2022 11:08:37 +0100 Subject: [PATCH 42/66] [Osquery] Fix role permissions for saved query and actions (#124932) * fix packs permission for saved query and actions --- .../add_integration.spec.ts | 5 +- .../{superuser => all}/alerts.spec.ts | 0 .../delete_all_ecs_mappings.spec.ts | 2 +- .../{superuser => all}/live_query.spec.ts | 16 ++- .../{superuser => all}/metrics.spec.ts | 2 +- .../{superuser => all}/packs.spec.ts | 33 ++--- .../integration/all/saved_queries.spec.ts | 23 ++++ .../cypress/integration/roles/reader.spec.ts | 80 +++++++++++++ .../integration/roles/t1_analyst.spec.ts | 99 +++++++++++++++ .../integration/roles/t2_analyst.spec.ts | 113 ++++++++++++++++++ .../integration/t1_analyst/live_query.spec.ts | 30 ----- .../plugins/osquery/cypress/screens/fleet.ts | 1 + .../osquery/cypress/screens/integrations.ts | 3 + .../osquery/cypress/screens/live_query.ts | 4 + .../saved_queries.ts} | 39 +++--- .../osquery/public/actions/actions_table.tsx | 9 +- ...squery_managed_custom_button_extension.tsx | 23 ++-- ...managed_policy_create_import_extension.tsx | 41 ++++--- .../fleet_integration/use_fetch_status.tsx | 36 ++++++ .../public/live_queries/form/index.tsx | 22 +++- .../packs/pack_queries_status_table.tsx | 21 ++-- .../routes/saved_queries/list/index.tsx | 12 +- .../osquery/scripts/roles_users/README.md | 2 +- .../osquery/scripts/roles_users/index.ts | 2 + .../scripts/roles_users/reader/delete_user.sh | 11 ++ .../scripts/roles_users/reader/get_role.sh | 11 ++ .../scripts/roles_users/reader/index.ts | 11 ++ .../scripts/roles_users/reader/post_role.sh | 14 +++ .../scripts/roles_users/reader/post_user.sh | 14 +++ .../scripts/roles_users/reader/role.json | 19 +++ .../scripts/roles_users/reader/user.json | 6 + .../scripts/roles_users/t1_analyst/role.json | 2 +- .../scripts/roles_users/t1_analyst/user.json | 2 +- .../roles_users/t2_analyst/delete_user.sh | 11 ++ .../roles_users/t2_analyst/get_role.sh | 11 ++ .../scripts/roles_users/t2_analyst/index.ts | 11 ++ .../roles_users/t2_analyst/post_role.sh | 14 +++ .../roles_users/t2_analyst/post_user.sh | 14 +++ .../scripts/roles_users/t2_analyst/role.json | 19 +++ .../scripts/roles_users/t2_analyst/user.json | 6 + .../routes/action/create_action_route.ts | 35 ++++-- 41 files changed, 679 insertions(+), 150 deletions(-) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/add_integration.spec.ts (97%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/alerts.spec.ts (100%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/delete_all_ecs_mappings.spec.ts (96%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/live_query.spec.ts (73%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/metrics.spec.ts (97%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/packs.spec.ts (90%) create mode 100644 x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts delete mode 100644 x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts rename x-pack/plugins/osquery/cypress/{integration/superuser/saved_queries.spec.ts => tasks/saved_queries.ts} (76%) create mode 100644 x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/index.ts create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/role.json create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/user.json create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index 4f9fb4304fd28..11a904526d314 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -11,8 +11,9 @@ import { addIntegration } from '../../tasks/integrations'; import { login } from '../../tasks/login'; // import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { DEFAULT_POLICY } from '../../screens/fleet'; -describe('Super User - Add Integration', () => { +describe('ALL - Add Integration', () => { const integration = 'Osquery Manager'; before(() => { runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); @@ -65,7 +66,7 @@ describe('Super User - Add Integration', () => { it('add integration', () => { cy.visit(FLEET_AGENT_POLICIES); - cy.contains('Default Fleet Server policy').click(); + cy.contains(DEFAULT_POLICY).click(); cy.contains('Add integration').click(); cy.contains(integration).click(); addIntegration(); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts similarity index 100% rename from x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts similarity index 96% rename from x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts index 5c21f29b650e7..46d927329aa98 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts @@ -9,7 +9,7 @@ import { navigateTo } from '../../tasks/navigation'; import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('SuperUser - Delete ECS Mappings', () => { +describe('ALL - Delete ECS Mappings', () => { const SAVED_QUERY_ID = 'Saved-Query-Id'; before(() => { diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts similarity index 73% rename from x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts index f979f793873f1..d6af17596d89a 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts @@ -15,8 +15,10 @@ import { typeInECSFieldInput, typeInOsqueryFieldInput, } from '../../tasks/live_query'; +import { RESULTS_TABLE_CELL_WRRAPER } from '../../screens/live_query'; +import { getAdvancedButton } from '../../screens/integrations'; -describe('Super User - Live Query', () => { +describe('ALL - Live Query', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); @@ -31,23 +33,25 @@ describe('Super User - Live Query', () => { // checking submit by clicking cmd+enter inputQuery(cmd); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.contains('View in Discover').should('exist'); + cy.contains('View in Lens').should('exist'); + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.hours.number', index: 2 }, }); - cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + getAdvancedButton().click(); typeInECSFieldInput('message{downArrow}{enter}'); typeInOsqueryFieldInput('days{downArrow}{enter}'); submitQuery(); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'message', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 2 }, }).react('EuiIconIndexMapping'); }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts index f64e6b31ae7a5..ba71e75d9ea7b 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts @@ -10,7 +10,7 @@ import { login } from '../../tasks/login'; import { checkResults, inputQuery, submitQuery } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('Super User - Metrics', () => { +describe('ALL - Metrics', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts similarity index 90% rename from x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts index fd04d0a62b160..eafe36874244e 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts @@ -16,8 +16,10 @@ import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { preparePack } from '../../tasks/packs'; import { addIntegration, closeModalIfVisible } from '../../tasks/integrations'; +import { DEFAULT_POLICY } from '../../screens/fleet'; +import { getSavedQueriesDropdown } from '../../screens/live_query'; -describe('SuperUser - Packs', () => { +describe('ALL - Packs', () => { const integration = 'Osquery Manager'; const SAVED_QUERY_ID = 'Saved-Query-Id'; const PACK_NAME = 'Pack-name'; @@ -47,21 +49,15 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add pack'); findFormFieldByRowsLabelAndType('Name', PACK_NAME); findFormFieldByRowsLabelAndType('Description (optional)', 'Pack description'); - findFormFieldByRowsLabelAndType( - 'Scheduled agent policies (optional)', - 'Default Fleet Server policy' - ); + findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', DEFAULT_POLICY); cy.react('List').first().click(); findAndClickButton('Add query'); cy.contains('Attach next query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.react('EuiFormRow', { props: { label: 'Interval (s)' } }) .click() .clear() - .type('10'); + .type('500'); cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click(); cy.react('EuiTableRow').contains(SAVED_QUERY_ID); findAndClickButton('Save pack'); @@ -94,10 +90,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); cy.contains('Attach next query'); cy.contains('ID must be unique').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.contains('ID must be unique').should('exist'); cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click(); }); @@ -175,9 +168,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('Multiple {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('Multiple {downArrow} {enter}'); cy.contains('Custom key/value pairs'); cy.contains('Days of uptime'); cy.contains('List of keywords used to tag each'); @@ -185,9 +176,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.'); cy.contains('Total uptime seconds'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('NOMAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('NOMAPPING {downArrow} {enter}'); cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Days of uptime').should('not.exist'); cy.contains('List of keywords used to tag each').should('not.exist'); @@ -195,9 +184,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.').should('not.exist'); cy.contains('Total uptime seconds').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('ONE_MAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('ONE_MAPPING {downArrow} {enter}'); cy.contains('Name of the continent'); cy.contains('Seconds of uptime'); diff --git a/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts new file mode 100644 index 0000000000000..4e48e819ac0ab --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { navigateTo } from '../../tasks/navigation'; + +import { login } from '../../tasks/login'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +const SAVED_QUERY_ID = 'Saved-Query-Id'; +const SAVED_QUERY_DESCRIPTION = 'Test saved query description'; + +describe('ALL - Saved queries', () => { + beforeEach(() => { + login(); + navigateTo('/app/osquery'); + }); + + getSavedQueriesComplexTest(SAVED_QUERY_ID, SAVED_QUERY_DESCRIPTION); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts new file mode 100644 index 0000000000000..d3a00f970322b --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; + +describe('Reader - only READ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.reader); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should not be able to add nor run saved queries', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.react('EuiFormRow', { props: { label: 'ID' } }) + .getBySel('input') + .should('be.disabled'); + cy.react('EuiFormRow', { props: { label: 'Description (optional)' } }) + .getBySel('input') + .should('be.disabled'); + + cy.contains('Update query').should('not.exist'); + cy.contains(`Delete query`).should('not.exist'); + }); + it('should not be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('be.disabled'); + cy.contains('select * from uptime'); + cy.react('EuiIconPlay', { options: { timeout: 3000 } }).should('not.exist'); + cy.react('ActionTableResultsButton').should('exist'); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts new file mode 100644 index 0000000000000..64d72c92dda04 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { checkResults, selectAllAgents, submitQuery } from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesDropdown, LIVE_QUERY_EDITOR } from '../../screens/live_query'; + +describe('T1 Analyst - READ + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.t1_analyst); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should be able to run saved queries but not add new ones', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + }) + .should('not.be.disabled') + .click(); + selectAllAgents(); + cy.contains('select * from uptime;'); + submitQuery(); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + }); + it('should be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled'); + cy.contains('select * from uptime'); + cy.wait(1000); + cy.react('EuiTableBody').first().react('DefaultItemAction').first().click(); + selectAllAgents(); + cy.contains(SAVED_QUERY_ID); + submitQuery(); + checkResults(); + }); + it('should be able to use saved query in a new query', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled').click(); + selectAllAgents(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow} {enter}`); + cy.contains('select * from uptime'); + submitQuery(); + checkResults(); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + it('should not be able to create new liveQuery from scratch', () => { + navigateTo('/app/osquery'); + + cy.contains('New live query').click(); + selectAllAgents(); + cy.get(LIVE_QUERY_EDITOR).should('not.exist'); + cy.contains('Submit').should('be.disabled'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts new file mode 100644 index 0000000000000..805eb134a44f5 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { + checkResults, + selectAllAgents, + submitQuery, + inputQuery, + typeInECSFieldInput, + typeInOsqueryFieldInput, +} from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + const NEW_SAVED_QUERY_ID = 'Saved-Query-Id-T2'; + const NEW_SAVED_QUERY_DESCRIPTION = 'Test saved query description T2'; + beforeEach(() => { + login(ROLES.t2_analyst); + navigateTo('/app/osquery'); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + getSavedQueriesComplexTest(NEW_SAVED_QUERY_ID, NEW_SAVED_QUERY_DESCRIPTION); + + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + + it('should run query and enable ecs mapping', () => { + const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}'; + cy.contains('New live query').click(); + selectAllAgents(); + inputQuery('select * from uptime; '); + cy.wait(500); + // checking submit by clicking cmd+enter + inputQuery(cmd); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.hours.number', index: 2 }, + }); + + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + typeInECSFieldInput('message{downArrow}{enter}'); + typeInOsqueryFieldInput('days{downArrow}{enter}'); + submitQuery(); + + checkResults(); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'message', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 2 }, + }).react('EuiIconIndexMapping'); + }); + it('to click the edit button and edit pack', () => { + navigateTo('/app/osquery/saved_queries'); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs.').should('exist'); + cy.contains('Hours of uptime').should('exist'); + cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click(); + cy.react('EuiButton').contains('Update query').click(); + cy.wait(5000); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs').should('not.exist'); + cy.contains('Hours of uptime').should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts deleted file mode 100644 index 11c78560d25fe..0000000000000 --- a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { login } from '../../tasks/login'; -import { navigateTo } from '../../tasks/navigation'; -import { ROLES } from '../../test'; -import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; - -describe('T1 Analyst - Live Query', () => { - beforeEach(() => { - login(ROLES.t1_analyst); - }); - - describe('should run a live query', () => { - before(() => { - runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); - }); - after(() => { - runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); - }); - it('when passed as a saved query ', () => { - navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(1000); - }); - }); -}); diff --git a/x-pack/plugins/osquery/cypress/screens/fleet.ts b/x-pack/plugins/osquery/cypress/screens/fleet.ts index 6be51e5ed24bc..b7cce6484c405 100644 --- a/x-pack/plugins/osquery/cypress/screens/fleet.ts +++ b/x-pack/plugins/osquery/cypress/screens/fleet.ts @@ -9,3 +9,4 @@ export const ADD_AGENT_BUTTON = 'addAgentButton'; export const AGENT_POLICIES_TAB = 'fleet-agent-policies-tab'; export const ENROLLMENT_TOKENS_TAB = 'fleet-enrollment-tokens-tab'; +export const DEFAULT_POLICY = 'Default Fleet Server policy'; diff --git a/x-pack/plugins/osquery/cypress/screens/integrations.ts b/x-pack/plugins/osquery/cypress/screens/integrations.ts index 42c22096cea96..b02efb9cff512 100644 --- a/x-pack/plugins/osquery/cypress/screens/integrations.ts +++ b/x-pack/plugins/osquery/cypress/screens/integrations.ts @@ -24,3 +24,6 @@ export const LATEST_VERSION = 'latestVersion'; export const PACKAGE_VERSION = 'packageVersionText'; export const SAVE_PACKAGE_CONFIRM = '[data-test-subj=confirmModalConfirmButton]'; + +export const getAdvancedButton = () => + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index cba4a35c05719..54c19fe508705 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -9,4 +9,8 @@ export const AGENT_FIELD = '[data-test-subj="comboBoxInput"]'; export const ALL_AGENTS_OPTION = '[title="All agents"]'; export const LIVE_QUERY_EDITOR = '#osquery_editor'; export const SUBMIT_BUTTON = '#submit-button'; + export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; +export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper'; +export const getSavedQueriesDropdown = () => + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts similarity index 76% rename from x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts rename to x-pack/plugins/osquery/cypress/tasks/saved_queries.ts index bc8417d5facf5..bfa7b51643382 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts +++ b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { navigateTo } from '../../tasks/navigation'; -import { RESULTS_TABLE_BUTTON } from '../../screens/live_query'; +import { RESULTS_TABLE_BUTTON } from '../screens/live_query'; import { checkResults, BIG_QUERY, @@ -15,18 +14,9 @@ import { inputQuery, selectAllAgents, submitQuery, -} from '../../tasks/live_query'; -import { login } from '../../tasks/login'; - -describe('Super User - Saved queries', () => { - const SAVED_QUERY_ID = 'Saved-Query-Id'; - const SAVED_QUERY_DESCRIPTION = 'Saved Query Description'; - - beforeEach(() => { - login(); - navigateTo('/app/osquery'); - }); +} from './live_query'; +export const getSavedQueriesComplexTest = (savedQueryId: string, savedQueryDescription: string) => it( 'should create a new query and verify: \n ' + '- hidden columns, full screen and sorting \n' + @@ -78,8 +68,8 @@ describe('Super User - Saved queries', () => { cy.contains('Exit full screen').should('not.exist'); cy.contains('Save for later').click(); cy.contains('Save query'); - findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID); - findFormFieldByRowsLabelAndType('Description (optional)', SAVED_QUERY_DESCRIPTION); + findFormFieldByRowsLabelAndType('ID', savedQueryId); + findFormFieldByRowsLabelAndType('Description (optional)', savedQueryDescription); cy.react('EuiButtonDisplay').contains('Save').click(); // visit Status results @@ -89,31 +79,30 @@ describe('Super User - Saved queries', () => { // play saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('PlayButtonComponent', { - props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + props: { savedQuery: { attributes: { id: savedQueryId } } }, }).click(); selectAllAgents(); submitQuery(); // edit saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); findFormFieldByRowsLabelAndType('Description (optional)', ' Edited'); cy.react('EuiButton').contains('Update query').click(); - cy.contains(`${SAVED_QUERY_DESCRIPTION} Edited`); + cy.contains(`${savedQueryDescription} Edited`); // delete saved query - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); deleteAndConfirm('query'); - cy.contains(SAVED_QUERY_ID).should('exist'); - cy.contains(SAVED_QUERY_ID).should('not.exist'); + cy.contains(savedQueryId).should('exist'); + cy.contains(savedQueryId).should('not.exist'); } ); -}); diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index d92d9ee117fde..2f81394bccde8 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -13,7 +13,7 @@ import { useHistory } from 'react-router-dom'; import { useAllActions } from './use_all_actions'; import { Direction } from '../../common/search_strategy'; -import { useRouterNavigate } from '../common/lib/kibana'; +import { useRouterNavigate, useKibana } from '../common/lib/kibana'; interface ActionTableResultsButtonProps { actionId: string; @@ -28,6 +28,7 @@ const ActionTableResultsButton: React.FC = ({ act ActionTableResultsButton.displayName = 'ActionTableResultsButton'; const ActionsTableComponent = () => { + const permissions = useKibana().services.application.capabilities.osquery; const { push } = useHistory(); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(20); @@ -84,6 +85,10 @@ const ActionsTableComponent = () => { }), [push] ); + const isPlayButtonAvailable = useCallback( + () => permissions.runSavedQueries || permissions.writeLiveQueries, + [permissions.runSavedQueries, permissions.writeLiveQueries] + ); const columns = useMemo( () => [ @@ -128,6 +133,7 @@ const ActionsTableComponent = () => { type: 'icon', icon: 'play', onClick: handlePlayClick, + available: isPlayButtonAvailable, }, { render: renderActionsColumn, @@ -137,6 +143,7 @@ const ActionsTableComponent = () => { ], [ handlePlayClick, + isPlayButtonAvailable, renderActionsColumn, renderAgentsColumn, renderCreatedByColumn, diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx index c3770f202c087..0f5caca5d19bd 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx @@ -6,12 +6,13 @@ */ import { EuiLoadingContent } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React from 'react'; import { PackageCustomExtensionComponentProps } from '../../../fleet/public'; import { NavigationButtons } from './navigation_buttons'; import { DisabledCallout } from './disabled_callout'; -import { useKibana } from '../common/lib/kibana'; +import { MissingPrivileges } from '../routes/components/missing_privileges'; +import { useFetchStatus } from './use_fetch_status'; /** * Exports Osquery-specific package policy instructions @@ -19,22 +20,16 @@ import { useKibana } from '../common/lib/kibana'; */ export const OsqueryManagedCustomButtonExtension = React.memo( () => { - const [disabled, setDisabled] = React.useState(null); - const { http } = useKibana().services; + const { loading, disabled, permissionDenied } = useFetchStatus(); - useEffect(() => { - const fetchStatus = () => { - http.get<{ install_status: string }>('/internal/osquery/status').then((response) => { - setDisabled(response?.install_status !== 'installed'); - }); - }; - fetchStatus(); - }, [http]); - - if (disabled === null) { + if (loading) { return ; } + if (permissionDenied) { + return ; + } + return ( <> {disabled ? : null} diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 1b7b87fe180bf..aaedec1e0dbe1 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -46,6 +46,7 @@ import { fieldValidators, ValidationFunc, } from '../shared_imports'; +import { useFetchStatus } from './use_fetch_status'; // https://github.com/elastic/beats/blob/master/x-pack/osquerybeat/internal/osqd/args.go#L57 const RESTRICTED_CONFIG_OPTIONS = [ @@ -340,6 +341,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const { permissionDenied } = useFetchStatus(); + return ( <> {!editMode ? : null} @@ -366,23 +369,27 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< ) : null} - - - - -
    - - - -
    + {!permissionDenied && ( + <> + + + + +
    + + + +
    + + )} ); }); diff --git a/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx new file mode 100644 index 0000000000000..3f86675f8be41 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import { useKibana } from '../common/lib/kibana'; + +export const useFetchStatus = () => { + const [loading, setLoading] = useState(true); + const [disabled, setDisabled] = useState(false); + const [permissionDenied, setPermissionDenied] = useState(false); + const { http } = useKibana().services; + + useEffect(() => { + const fetchStatus = () => { + http + .get<{ install_status: string }>('/internal/osquery/status') + .then((response) => { + setLoading(false); + setDisabled(response?.install_status !== 'installed'); + }) + .catch((err) => { + setLoading(false); + if (err.body.statusCode === 403) { + setPermissionDenied(true); + } + }); + }; + fetchStatus(); + }, [http]); + + return { loading, disabled, permissionDenied }; +}; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index bd8e2bf42129f..9164266d6a8c5 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -273,16 +273,26 @@ const LiveQueryFormComponent: React.FC = ({ [permissions.writeSavedQueries] ); + const isSavedQueryDisabled = useMemo( + () => + queryComponentProps.disabled || !permissions.runSavedQueries || !permissions.readSavedQueries, + [permissions.readSavedQueries, permissions.runSavedQueries, queryComponentProps.disabled] + ); + const queryFieldStepContent = useMemo( () => ( <> {queryField ? ( <> - - + {!isSavedQueryDisabled && ( + <> + + + + )} = ({ [ queryField, queryComponentProps, - permissions.runSavedQueries, permissions.writeSavedQueries, handleSavedQueryChange, ecsMappingField, @@ -372,6 +381,7 @@ const LiveQueryFormComponent: React.FC = ({ enabled, isSubmitting, submit, + isSavedQueryDisabled, ] ); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 836350d12d43e..c99662804b1e2 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -207,6 +207,7 @@ const ViewResultsInLensActionComponent: React.FC { const lensService = useKibana().services.lens; + const isLensAvailable = lensService?.canUseEditor(); const handleClick = useCallback( (event) => { @@ -230,14 +231,12 @@ const ViewResultsInLensActionComponent: React.FC + {VIEW_IN_LENS} ); @@ -247,7 +246,7 @@ const ViewResultsInLensActionComponent: React.FC @@ -264,7 +263,10 @@ const ViewResultsInDiscoverActionComponent: React.FC { - const locator = useKibana().services.discover?.locator; + const { discover, application } = useKibana().services; + const locator = discover?.locator; + const discoverPermissions = application.capabilities.discover; + const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { @@ -336,6 +338,9 @@ const ViewResultsInDiscoverActionComponent: React.FC diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx index f16e32a62cb4f..d019b831d96f5 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -125,12 +125,12 @@ const SavedQueriesPageComponent = () => { ); const renderPlayAction = useCallback( - (item: SavedQuerySO) => ( - - ), + (item: SavedQuerySO) => + permissions.runSavedQueries || permissions.writeLiveQueries ? ( + + ) : ( + <> + ), [permissions.runSavedQueries, permissions.writeLiveQueries] ); diff --git a/x-pack/plugins/osquery/scripts/roles_users/README.md b/x-pack/plugins/osquery/scripts/roles_users/README.md index d0a28049c865b..aadc696a5f504 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/README.md +++ b/x-pack/plugins/osquery/scripts/roles_users/README.md @@ -4,7 +4,7 @@ Initial version of roles support for Osquery |:--------------------------------------------:|:-----------------------------------------------:|:-------------------------------:|:-------------:|:-----:|:-------------------:|:-----:|:-------------:|:----------:| | NO Data Source access user | none | none | none | none | none | none | none | none | | Reader (read-only user) | read | read | read | read | none | none | none | none | -| T1 Analyst | read | read, write (run saved queries) | read | read | none | none | none | none | +| T1 Analyst | read | read, (run saved queries) | read | read | none | none | none | none | | T2 Analyst | read | read, write (tbc) | all | read | none | read | none | none | | Hunter / T3 Analyst | read | all | all | all | none | all | read | all | | SOC Manager | read | all | all | all | none | all | read | all | diff --git a/x-pack/plugins/osquery/scripts/roles_users/index.ts b/x-pack/plugins/osquery/scripts/roles_users/index.ts index 1f51d8691a715..ce29ba92e2590 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/index.ts +++ b/x-pack/plugins/osquery/scripts/roles_users/index.ts @@ -5,4 +5,6 @@ * 2.0. */ +export * from './reader'; export * from './t1_analyst'; +export * from './t2_analyst'; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh new file mode 100755 index 0000000000000..57704f7abf0d3 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/reader diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh new file mode 100755 index 0000000000000..37db6e10ced55 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/reader | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts new file mode 100644 index 0000000000000..6fbd33c69b3a6 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as readerUser from './user.json'; +import * as readerRole from './role.json'; + +export { readerUser, readerRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh new file mode 100755 index 0000000000000..338783465f993 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/reader \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh new file mode 100755 index 0000000000000..8a93326a820b7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/reader \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/role.json b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json new file mode 100644 index 0000000000000..85c2ff52f84d6 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/user.json b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json new file mode 100644 index 0000000000000..a6c3c38cdd16e --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["reader"], + "full_name": "Reader", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json index 85c2ff52f84d6..12d5c2607f9ab 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json @@ -10,7 +10,7 @@ "kibana": [ { "feature": { - "osquery": ["read"] + "osquery": ["read", "run_saved_queries" ] }, "spaces": ["*"] } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json index 203abec8ad433..cef1935d57068 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json @@ -2,5 +2,5 @@ "password": "changeme", "roles": ["t1_analyst"], "full_name": "T1 Analyst", - "email": "detections-reader@example.com" + "email": "osquery@example.com" } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh new file mode 100755 index 0000000000000..6dccb0d8c6067 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/t2_analyst diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh new file mode 100755 index 0000000000000..ce9149d8b9fc7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/t2_analyst | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts new file mode 100644 index 0000000000000..a3a8357e67c7f --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t2AnalystUser from './user.json'; +import * as t2AnalystRole from './role.json'; + +export { t2AnalystUser, t2AnalystRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh new file mode 100755 index 0000000000000..b94c738c3e3db --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/t2_analyst \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh new file mode 100755 index 0000000000000..3a901490515af --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/t2_analyst \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json new file mode 100644 index 0000000000000..43133a62ec56b --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read", "live_queries_all", "saved_queries_all", "packs_read", "run_saved_queries"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json new file mode 100644 index 0000000000000..36096b2cc8f06 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["t2_analyst"], + "full_name": "T2 Analyst", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 37c08d712e3f6..b37e6032331dd 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -9,7 +9,6 @@ import { pickBy, isEmpty } from 'lodash'; import uuid from 'uuid'; import moment from 'moment-timezone'; -import { PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; @@ -22,6 +21,7 @@ import { import { incrementCount } from '../usage'; import { getInternalSavedObjectsClient } from '../../usage/collector'; +import { savedQuerySavedObjectType } from '../../../common/types'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( @@ -33,15 +33,38 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon CreateActionRequestBodySchema >(createActionRequestBodySchema), }, - options: { - tags: [`access:${PLUGIN_ID}-readLiveQueries`, `access:${PLUGIN_ID}-runSavedQueries`], - }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; + const soClient = context.core.savedObjects.client; const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); + const [coreStartServices] = await osqueryContext.getStartServices(); + let savedQueryId = request.body.saved_query_id; + + const { + osquery: { writeLiveQueries, runSavedQueries }, + } = await coreStartServices.capabilities.resolveCapabilities(request); + + const isInvalid = !(writeLiveQueries || (runSavedQueries && request.body.saved_query_id)); + + if (isInvalid) { + return response.forbidden(); + } + + if (request.body.saved_query_id && runSavedQueries) { + const savedQueries = await soClient.find({ + type: savedQuerySavedObjectType, + }); + const actualSavedQuery = savedQueries.saved_objects.find( + (savedQuery) => savedQuery.id === request.body.saved_query_id + ); + + if (actualSavedQuery) { + savedQueryId = actualSavedQuery.id; + } + } const { agentSelection } = request.body as { agentSelection: AgentSelection }; const selectedAgents = await parseAgentSelection( @@ -55,8 +78,6 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon return response.badRequest({ body: new Error('No agents found for selection') }); } - // TODO: Add check for `runSavedQueries` only - try { const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username; const action = { @@ -71,7 +92,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon { id: uuid.v4(), query: request.body.query, - saved_query_id: request.body.saved_query_id, + saved_query_id: savedQueryId, ecs_mapping: request.body.ecs_mapping, }, (value) => !isEmpty(value) From 96515f559162c54896cc356193a2a531c1e3b6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 24 Mar 2022 11:11:51 +0100 Subject: [PATCH 43/66] [ILM] Removed `stack_trace` usage from ILM extension for Index Management (#128397) * [ILM] Removed `stack_trace` usage from ILM (Index Management extension) * Fixed i18n files --- .../extend_index_management.test.tsx.snap | 76 ------------------- .../__jest__/extend_index_management.test.tsx | 1 - .../common/types/policies.ts | 1 - .../components/index_lifecycle_summary.tsx | 30 +------- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 7 files changed, 1 insertion(+), 110 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 25930c07fcd8b..802d684a8a261 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -130,7 +130,6 @@ exports[`extend index management ilm summary extension should return extension w "step": "ERROR", "step_info": Object { "reason": "setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined", - "stack_trace": "fakestacktrace", "type": "illegal_argument_exception", }, "step_time_millis": 1544187776208, @@ -332,81 +331,6 @@ exports[`extend index management ilm summary extension should return extension w illegal_argument_exception : setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined - -
    - - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="stackPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > -
    -
    - - - -
    -
    -
    diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx index eaebd6381d984..544aad4c52088 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx @@ -113,7 +113,6 @@ const indexWithLifecycleError = { step_info: { type: 'illegal_argument_exception', reason: 'setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined', - stack_trace: 'fakestacktrace', }, phase_execution: { policy: 'testy', diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 085179f14913d..ad1b1b2b28880 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -229,7 +229,6 @@ export interface IndexLifecyclePolicy { step?: string; step_info?: { reason?: string; - stack_trace?: string; type?: string; message?: string; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx index 4a34a4eb11ea4..fa148a5ba960b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { - EuiButtonEmpty, EuiCallOut, EuiCodeBlock, EuiFlexGroup, @@ -108,31 +107,6 @@ export class IndexLifecycleSummary extends Component { closePhaseExecutionPopover = () => { this.setState({ showPhaseExecutionPopover: false }); }; - renderStackPopoverButton(ilm: IndexLifecyclePolicy) { - if (!ilm.step_info!.stack_trace) { - return null; - } - const button = ( - - - - ); - return ( - -
    -
    {ilm.step_info!.stack_trace}
    -
    -
    - ); - } renderPhaseExecutionPopoverButton(ilm: IndexLifecyclePolicy) { const button = ( @@ -257,12 +231,10 @@ export class IndexLifecycleSummary extends Component { iconType="cross" > {ilm.step_info.type}: {ilm.step_info.reason} - - {this.renderStackPopoverButton(ilm)} ) : null} - {ilm.step_info && ilm.step_info!.message && !ilm.step_info!.stack_trace ? ( + {ilm.step_info && ilm.step_info!.message ? ( <> Date: Thu, 24 Mar 2022 12:18:31 +0200 Subject: [PATCH 44/66] Add authentication to apis --- .../cloud_security_posture/server/plugin.ts | 3 +- .../routes/benchmarks/benchmarks.test.ts | 73 ++++++++++++++++++- .../server/routes/benchmarks/benchmarks.ts | 9 ++- .../compliance_dashboard.test.ts | 72 ++++++++++++++++++ .../compliance_dashboard.ts | 5 +- .../update_rules_configuration.test.ts | 56 +++++++++++++- .../update_rules_configuration.ts | 8 +- .../server/routes/findings/findings.test.ts | 49 +++++++++++++ .../server/routes/findings/findings.ts | 9 ++- .../server/routes/index.ts | 4 +- .../cloud_security_posture/server/types.ts | 29 +++++++- 11 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index f2f81ed608ba4..2709518ffbc5f 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -18,6 +18,7 @@ import type { CspServerPluginStart, CspServerPluginSetupDeps, CspServerPluginStartDeps, + CspRequestHandlerContext, } from './types'; import { defineRoutes } from './routes'; import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; @@ -55,7 +56,7 @@ export class CspPlugin core.savedObjects.registerType(cspRuleAssetType); - const router = core.http.createRouter(); + const router = core.http.createRouter(); // Register server side APIs defineRoutes(router, cspAppContext); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts index 8d33d3db189d3..f6363794213ac 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -4,7 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { httpServiceMock, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { + ElasticsearchClientMock, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from 'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; import { defineGetBenchmarksRoute, benchmarksInputSchema, @@ -14,6 +25,7 @@ import { getAgentPolicies, createBenchmarkEntry, } from './benchmarks'; + import { SavedObjectsClientContract } from 'src/core/server'; import { createMockAgentPolicyService, @@ -25,6 +37,17 @@ import { AgentPolicy } from '../../../../fleet/common'; import { CspAppService } from '../../lib/csp_app_services'; import { CspAppContext } from '../../plugin'; +export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): KibanaRequest => { + return { + core: { + elasticsearch: { + client: { asCurrentUser: mockEsClient }, + }, + }, + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; +}; + function createMockAgentPolicy(props: Partial = {}): AgentPolicy { return { id: 'some-uuid1', @@ -66,6 +89,54 @@ describe('benchmarks API', () => { expect(config.path).toEqual('/api/csp/benchmarks'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = benchmarksInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index 1e6eadb0c77f6..366fcd9e409e9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -5,7 +5,7 @@ * 2.0. */ import { uniq, map } from 'lodash'; -import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import type { SavedObjectsClientContract } from 'src/core/server'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { @@ -23,6 +23,7 @@ import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../com import { CspAppContext } from '../../plugin'; import type { Benchmark } from '../../../common/types'; import { isNonNullable } from '../../../common/utils/helpers'; +import { CspRouter } from '../../types'; type BenchmarksQuerySchema = TypeOf; @@ -132,13 +133,17 @@ const createBenchmarks = ( .filter(isNonNullable); }); -export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineGetBenchmarksRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: BENCHMARKS_ROUTE_PATH, validate: { query: benchmarksInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const soClient = context.core.savedObjects.client; const { query } = request; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts new file mode 100644 index 0000000000000..95addd9c055de --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import // eslint-disable-next-line @kbn/eslint/no-restricted-paths +'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +describe('compliance dashboard permissions API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts index f554eb91a4a49..e414dab92606a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import type { ElasticsearchClient } from 'src/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { AggregationsMultiBucketAggregateBase as Aggregation, @@ -19,6 +19,7 @@ import { CspAppContext } from '../../plugin'; import { getResourcesTypes } from './get_resources_types'; import { getClusters } from './get_clusters'; import { getStats } from './get_stats'; +import { CspRouter } from '../../types'; export interface ClusterBucket { ordered_top_hits: AggregationsTopHitsAggregate; @@ -75,7 +76,7 @@ const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise router.get( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts index 4e534d565d7e3..c558caea1e9d9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -6,7 +6,12 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { savedObjectsClientMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { + savedObjectsClientMock, + httpServiceMock, + loggingSystemMock, + httpServerMock, +} from 'src/core/server/mocks'; import { convertRulesConfigToYaml, createRulesConfig, @@ -24,6 +29,7 @@ import { createPackagePolicyServiceMock } from '../../../../fleet/server/mocks'; import { cspRuleAssetSavedObjectType, CspRuleSchema } from '../../../common/schemas/csp_rule'; import { ElasticsearchClient, + KibanaRequest, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'kibana/server'; @@ -55,6 +61,54 @@ describe('Update rules configuration API', () => { expect(config.path).toEqual('/api/csp/update_rules_config'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + it('validate getCspRules input parameters', async () => { mockSoClient = savedObjectsClientMock.create(); mockSoClient.find.mockResolvedValueOnce({} as SavedObjectsFindResponse); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts index 50a4759c5ec52..a57d3902f266c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -6,7 +6,6 @@ */ import type { ElasticsearchClient, - IRouter, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'src/core/server'; @@ -24,6 +23,7 @@ import { CspRuleSchema, cspRuleAssetSavedObjectType } from '../../../common/sche import { UPDATE_RULES_CONFIG_ROUTE_PATH } from '../../../common/constants'; import { CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; import { PackagePolicyServiceInterface } from '../../../../fleet/server'; +import { CspRouter } from '../../types'; export const getPackagePolicy = async ( soClient: SavedObjectsClientContract, @@ -99,13 +99,17 @@ export const updatePackagePolicy = ( return packagePolicyService.update(soClient, esClient, packagePolicy.id, updatedPackagePolicy); }; -export const defineUpdateRulesConfigRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineUpdateRulesConfigRoute = (router: CspRouter, cspContext: CspAppContext): void => router.post( { path: UPDATE_RULES_CONFIG_ROUTE_PATH, validate: { query: configurationUpdateInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const soClient = context.core.savedObjects.client; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index cfd180a86169d..c41245db04685 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -27,6 +27,7 @@ export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): Kibana client: { asCurrentUser: mockEsClient }, }, }, + fleet: { authz: { fleet: { all: true } } }, } as unknown as KibanaRequest; }; @@ -56,6 +57,54 @@ describe('findings API', () => { expect(config.path).toEqual('/api/csp/findings'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = findingsInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index ca95efae3d56a..cdbbfbe5ff69d 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { Logger } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { QueryDslBoolQuery } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -16,6 +16,7 @@ import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; import { CspAppContext } from '../../plugin'; +import { CspRouter } from '../../types'; type FindingsQuerySchema = TypeOf; @@ -103,13 +104,17 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineFindingsIndexRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, validate: { query: findingsInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const options = buildOptionsRequest(request.query); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index aa04a610aa486..a0981e2a956cd 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,14 +5,14 @@ * 2.0. */ -import type { IRouter } from '../../../../../src/core/server'; import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration'; import { CspAppContext } from '../plugin'; +import { CspRouter } from '../types'; -export function defineRoutes(router: IRouter, cspContext: CspAppContext) { +export function defineRoutes(router: CspRouter, cspContext: CspAppContext) { defineGetComplianceDashboardRoute(router, cspContext); defineGetFindingsIndexRoute(router, cspContext); defineGetBenchmarksRoute(router, cspContext); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 4e70027013df8..9fe602424321c 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,7 +10,14 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; -import type { FleetStartContract } from '../../fleet/server'; +import type { + RouteMethod, + KibanaResponseFactory, + RequestHandler, + IRouter, +} from '../../../../src/core/server'; + +import type { FleetStartContract, FleetRequestHandlerContext } from '../../fleet/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} @@ -29,3 +36,23 @@ export interface CspServerPluginStartDeps { data: DataPluginStart; fleet: FleetStartContract; } + +export type CspRequestHandlerContext = FleetRequestHandlerContext; + +/** + * Convenience type for request handlers in CSP that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRequestHandler< + P = unknown, + Q = unknown, + B = unknown, + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory +> = RequestHandler; + +/** + * Convenience type for routers in Csp that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRouter = IRouter; From ee3acc5e22d7b144ee7ba0d57cea0a13cefbe310 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 24 Mar 2022 11:07:33 +0000 Subject: [PATCH 45/66] Re-organize rule types in alerts (#128249) * Update anomaly rule descriptions * re-order rule types * renaming files and anomaly alert type * fix comment for anomaly --- x-pack/plugins/apm/common/alert_types.ts | 10 +-- .../apm/common/utils/formatters/alert_url.ts | 2 +- .../alerting/register_apm_alerts.ts | 12 ++- .../alerting_popover_flyout.tsx | 83 ++++++------------- ...ts => register_anomaly_alert_type.test.ts} | 12 +-- ...type.ts => register_anomaly_alert_type.ts} | 18 ++-- .../routes/alerts/register_apm_alerts.ts | 4 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 10 files changed, 52 insertions(+), 97 deletions(-) rename x-pack/plugins/apm/server/routes/alerts/{register_transaction_duration_anomaly_alert_type.test.ts => register_anomaly_alert_type.test.ts} (93%) rename x-pack/plugins/apm/server/routes/alerts/{register_transaction_duration_anomaly_alert_type.ts => register_anomaly_alert_type.ts} (94%) diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 04288fccf0a05..f2f021b81d76d 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -22,7 +22,7 @@ export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. TransactionErrorRate = 'apm.transaction_error_rate', TransactionDuration = 'apm.transaction_duration', - TransactionDurationAnomaly = 'apm.transaction_duration_anomaly', + Anomaly = 'apm.anomaly', } export const THRESHOLD_MET_GROUP_ID = 'threshold_met'; @@ -127,7 +127,7 @@ export function formatTransactionErrorRateReason({ }); } -export function formatTransactionDurationAnomalyReason({ +export function formatAnomalyReason({ serviceName, severityLevel, measured, @@ -188,9 +188,9 @@ export const ALERT_TYPES_CONFIG: Record< producer: APM_SERVER_FEATURE_ID, isExportable: true, }, - [AlertType.TransactionDurationAnomaly]: { - name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { - defaultMessage: 'Latency anomaly', + [AlertType.Anomaly]: { + name: i18n.translate('xpack.apm.anomalyAlert.name', { + defaultMessage: 'Anomaly', }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, diff --git a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts index a88f69b4ef5c7..982da4803cb57 100644 --- a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts +++ b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts @@ -27,7 +27,7 @@ export const getAlertUrlErrorCount = ( environment: serviceEnv ?? ENVIRONMENT_ALL.value, }, }); -// This formatter is for TransactionDuration, TransactionErrorRate, and TransactionDurationAnomaly. +// This formatter is for TransactionDuration, TransactionErrorRate, and Anomaly. export const getAlertUrlTransaction = ( serviceName: string, serviceEnv: string | undefined, diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 692165f2b2ff5..69ed7d73c3115 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -147,13 +147,11 @@ export function registerApmAlerts( }); observabilityRuleTypeRegistry.register({ - id: AlertType.TransactionDurationAnomaly, - description: i18n.translate( - 'xpack.apm.alertTypes.transactionDurationAnomaly.description', - { - defaultMessage: 'Alert when the latency of a service is abnormal.', - } - ), + id: AlertType.Anomaly, + description: i18n.translate('xpack.apm.alertTypes.anomaly.description', { + defaultMessage: + 'Alert when either the latency, throughput, or failed transaction rate of a service is anomalous.', + }), format: ({ fields }) => ({ reason: fields[ALERT_REASON]!, link: getAlertUrlTransaction( diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index f988917515fbb..164a413a548ee 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -30,7 +30,7 @@ const transactionErrorRateLabel = i18n.translate( { defaultMessage: 'Failed transaction rate' } ); const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { - defaultMessage: 'Error count', + defaultMessage: ' Create error count rule', }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', @@ -41,11 +41,7 @@ const createAnomalyAlertAlertLabel = i18n.translate( { defaultMessage: 'Create anomaly rule' } ); -const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = - 'create_transaction_duration_panel'; -const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = - 'create_transaction_error_rate_panel'; -const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; +const CREATE_THRESHOLD_PANEL_ID = 'create_threshold_panel'; interface Props { basePath: IBasePath; @@ -86,16 +82,26 @@ export function AlertingPopoverAndFlyout({ ...(canSaveAlerts ? [ { - name: transactionDurationLabel, - panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - }, - { - name: transactionErrorRateLabel, - panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_PANEL_ID, }, + ...(canReadAnomalies + ? [ + { + name: createAnomalyAlertAlertLabel, + onClick: () => { + setAlertType(AlertType.Anomaly); + setPopoverOpen(false); + }, + }, + ] + : []), { name: errorCountLabel, - panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + onClick: () => { + setAlertType(AlertType.ErrorCount); + setPopoverOpen(false); + }, }, ] : []), @@ -114,16 +120,16 @@ export function AlertingPopoverAndFlyout({ ], }, - // latency panel + // Threshold panel { - id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - title: transactionDurationLabel, + id: CREATE_THRESHOLD_PANEL_ID, + title: createThresholdAlertLabel, items: [ - // threshold alerts + // Latency ...(includeTransactionDuration ? [ { - name: createThresholdAlertLabel, + name: transactionDurationLabel, onClick: () => { setAlertType(AlertType.TransactionDuration); setPopoverOpen(false); @@ -131,30 +137,10 @@ export function AlertingPopoverAndFlyout({ }, ] : []), - - // anomaly alerts - ...(canReadAnomalies - ? [ - { - name: createAnomalyAlertAlertLabel, - onClick: () => { - setAlertType(AlertType.TransactionDurationAnomaly); - setPopoverOpen(false); - }, - }, - ] - : []), - ], - }, - - // Failed transactions panel - { - id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, - title: transactionErrorRateLabel, - items: [ - // threshold alerts + // Throughput *** TO BE ADDED *** + // Failed transactions rate { - name: createThresholdAlertLabel, + name: transactionErrorRateLabel, onClick: () => { setAlertType(AlertType.TransactionErrorRate); setPopoverOpen(false); @@ -162,21 +148,6 @@ export function AlertingPopoverAndFlyout({ }, ], }, - - // error alerts panel - { - id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, - title: errorCountLabel, - items: [ - { - name: createThresholdAlertLabel, - onClick: () => { - setAlertType(AlertType.ErrorCount); - setPopoverOpen(false); - }, - }, - ], - }, ]; return ( diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts similarity index 93% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts index 2bb8530ca03f6..2f4245c89694a 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; @@ -19,7 +19,7 @@ describe('Transaction duration anomaly alert', () => { it('ml is not defined', async () => { const { services, dependencies, executor } = createRuleTypeMocks(); - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml: undefined, }); @@ -47,7 +47,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -98,7 +98,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -174,7 +174,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -190,7 +190,7 @@ describe('Transaction duration anomaly alert', () => { expect(services.alertFactory.create).toHaveBeenCalledTimes(1); expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.transaction_duration_anomaly_foo_development_type-foo' + 'apm.anomaly_foo_development_type-foo' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts similarity index 94% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts index 64f06c9f638f1..04d1fb775cea0 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts @@ -32,7 +32,7 @@ import { AlertType, ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, - formatTransactionDurationAnomalyReason, + formatAnomalyReason, } from '../../../common/alert_types'; import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; @@ -57,10 +57,9 @@ const paramsSchema = schema.object({ ]), }); -const alertTypeConfig = - ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly]; +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.Anomaly]; -export function registerTransactionDurationAnomalyAlertType({ +export function registerAnomalyAlertType({ logger, ruleDataClient, alerting, @@ -74,7 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({ alerting.registerType( createLifecycleRuleType({ - id: AlertType.TransactionDurationAnomaly, + id: AlertType.Anomaly, name: alertTypeConfig.name, actionGroups: alertTypeConfig.actionGroups, defaultActionGroupId: alertTypeConfig.defaultActionGroupId, @@ -215,7 +214,7 @@ export function registerTransactionDurationAnomalyAlertType({ compact(anomalies).forEach((anomaly) => { const { serviceName, environment, transactionType, score } = anomaly; const severityLevel = getSeverity(score); - const reasonMessage = formatTransactionDurationAnomalyReason({ + const reasonMessage = formatAnomalyReason({ measured: score, serviceName, severityLevel, @@ -237,12 +236,7 @@ export function registerTransactionDurationAnomalyAlertType({ : relativeViewInAppUrl; services .alertWithLifecycle({ - id: [ - AlertType.TransactionDurationAnomaly, - serviceName, - environment, - transactionType, - ] + id: [AlertType.Anomaly, serviceName, environment, transactionType] .filter((name) => name) .join('_'), fields: { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts index 4556abfea1ee5..dfe0310e919b4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts @@ -10,7 +10,7 @@ import { IBasePath, Logger } from 'kibana/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../alerting/server'; import { IRuleDataClient } from '../../../../rule_registry/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; @@ -27,7 +27,7 @@ export interface RegisterRuleDependencies { export function registerApmAlerts(dependencies: RegisterRuleDependencies) { registerTransactionDurationAlertType(dependencies); - registerTransactionDurationAnomalyAlertType(dependencies); + registerAnomalyAlertType(dependencies); registerErrorCountAlertType(dependencies); registerTransactionErrorRateAlertType(dependencies); } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8b74d0cc3e1ea..1595abb458a25 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5861,7 +5861,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de latence : \\{\\{context.threshold\\}\\} ms\n- Latence observée : \\{\\{context.triggerValue\\}\\} sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "Alerte lorsque la latence d'un type de transaction spécifique dans un service dépasse le seuil défini.", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de sévérité : \\{\\{context.threshold\\}\\}\n- Valeur de sévérité : \\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "Alerte lorsque la latence d'un service est anormale.", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil : \\{\\{context.threshold\\}\\} %\n- Valeur de déclenchement : \\{\\{context.triggerValue\\}\\} % des erreurs sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionErrorRate.description": "Alerte lorsque le taux d'erreurs de transaction d'un service dépasse un seuil défini.", "xpack.apm.analyzeDataButton.label": "Analyser les données", @@ -6405,7 +6404,6 @@ "xpack.apm.transactionDurationAlert.name": "Seuil de latence", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "Quand", - "xpack.apm.transactionDurationAnomalyAlert.name": "Anomalie de latence", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "Comporte une anomalie avec sévérité", "xpack.apm.transactionDurationLabel": "Durée", "xpack.apm.transactionErrorRateAlert.name": "Seuil du taux de transactions ayant échoué", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b743ecb844df2..e469741130081 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6823,8 +6823,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- レイテンシしきい値:\\{\\{context.threshold\\}\\}ミリ秒\n- 観察されたレイテンシ:直前の\\{\\{context.interval\\}\\}に\\{\\{context.triggerValue\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "サービスの特定のトランザクションタイプのレイテンシが定義されたしきい値を超えたときにアラートを発行します。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- 重要度しきい値:\\{\\{context.threshold\\}\\}%\n- 重要度値:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "サービスのレイテンシが異常であるときにアラートを表示します。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "過去{interval}における{serviceName}のスコア{measured}の{severityLevel}異常が検知されました。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- しきい値:\\{\\{context.threshold\\}\\}%\n- トリガーされた値:過去\\{\\{context.interval\\}\\}にエラーの\\{\\{context.triggerValue\\}\\}%", "xpack.apm.alertTypes.transactionErrorRate.description": "サービスのトランザクションエラー率が定義されたしきい値を超過したときにアラートを発行します。", "xpack.apm.analyzeDataButton.label": "データの探索", @@ -7638,7 +7636,6 @@ "xpack.apm.transactionDurationAlert.name": "レイテンシしきい値", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "タイミング", - "xpack.apm.transactionDurationAnomalyAlert.name": "レイテンシ異常値", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "異常と重要度があります", "xpack.apm.transactionDurationLabel": "期間", "xpack.apm.transactionErrorRateAlert.name": "失敗したトランザクション率しきい値", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b3fa235b3dac2..165d3814809b7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6837,8 +6837,6 @@ "xpack.apm.alertTypes.transactionDuration.description": "当服务中特定事务类型的延迟超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionDuration.reason": "对于 {serviceName},过去 {interval}的 {aggregationType} 延迟为 {measured}。超出 {threshold} 时告警。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 严重性阈值:\\{\\{context.threshold\\}\\}\n- 严重性值:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "服务的延迟异常时告警。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "对于 {serviceName},过去 {interval}检测到分数为 {measured} 的 {severityLevel} 异常。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 阈值:\\{\\{context.threshold\\}\\}%\n- 已触发的值:在过去 \\{\\{context.interval\\}\\}有 \\{\\{context.triggerValue\\}\\}% 的错误", "xpack.apm.alertTypes.transactionErrorRate.description": "当服务中的事务错误率超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionErrorRate.reason": "对于 {serviceName},过去 {interval}的失败事务数为 {measured}。超出 {threshold} 时告警。", @@ -7656,7 +7654,6 @@ "xpack.apm.transactionDurationAlert.name": "延迟阈值", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "当", - "xpack.apm.transactionDurationAnomalyAlert.name": "延迟异常", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "有异常,严重性为", "xpack.apm.transactionDurationLabel": "持续时间", "xpack.apm.transactionErrorRateAlert.name": "失败事务率阈值", From 2789f945f599c363882e61ab7926839b35e85fec Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 24 Mar 2022 12:16:52 +0100 Subject: [PATCH 46/66] Bump nodejs APM agent version to 3.31 (#128459) * use apm with #2618 * bump to 3.31 --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 24367fa77216e..af0168e125544 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.30.0", + "elastic-apm-node": "^3.31.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/yarn.lock b/yarn.lock index 5163a6e68be50..cdcf07b3e7341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12786,10 +12786,10 @@ ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.0.tgz#a38a85eae078e3f7f09edda86db6d6419a8ecfea" - integrity sha512-HB6+O0C4GGj9k5bd6yL3QK5prGKh+Rf8Tc5iW0T7FCdh2HliICfGmB6wmdQ2XkClblLtISh7tKYgVr9YgdXl3Q== +elastic-apm-http-client@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.1.tgz#15dbe99d56d62b3f732d1bd2b51bef6094b78801" + integrity sha512-5AOWlhs2WlZpI+DfgGqY/8Rk7KF8WeevaO8R961eBylavU6GWhLRNiJncohn5jsvrqhmeT19azBvy/oYRN7bJw== dependencies: agentkeepalive "^4.2.1" breadth-filter "^2.0.0" @@ -12802,10 +12802,10 @@ elastic-apm-http-client@11.0.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.30.0: - version "3.30.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.30.0.tgz#4df7110324535089f66f7a3a96bf37d2fe47f38b" - integrity sha512-KumRBDGIE+MGgJfteAi9BDqeGxpAYpbovWjNdB5x8T3/zpnQRJkDMSblliEsMwD6uKf2+Nkxzmyq9UZdh5MbGQ== +elastic-apm-node@^3.31.0: + version "3.31.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.31.0.tgz#6e0bf622d922c95ff0127a263babcdeaeea71457" + integrity sha512-0OulazfhkXYbOaGkHncqjwOfxtcvzsDyzUKr6Y1k95HwKrjf1Vi+xPutZv4p/WfDdO+JadphI0U2Uu5ncGB2iA== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -12814,7 +12814,7 @@ elastic-apm-node@^3.30.0: basic-auth "^2.0.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "11.0.0" + elastic-apm-http-client "11.0.1" end-of-stream "^1.4.4" error-callsites "^2.0.4" error-stack-parser "^2.0.6" From a6fa068cc147f2bbd661937b46ab8ff10ec71962 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Mar 2022 12:17:47 +0100 Subject: [PATCH 47/66] [Lens] Functional test: Set lucene filter which actually matches data (#128408) * set lucene filter which actually matches data * Update show_underlying_data.ts Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/functional/apps/lens/show_underlying_data.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index 2444e8714e014..cbe6820ccef4d 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -16,8 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/128396 - describe.skip('show underlying data', () => { + describe('show underlying data', () => { it('should show the open button for a compatible saved visualization', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); @@ -83,7 +82,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); // apparently setting a filter requires some time before and after typing to work properly await PageObjects.common.sleep(1000); - await PageObjects.lens.setFilterBy('memory'); + await PageObjects.lens.setFilterBy('memory:*'); await PageObjects.common.sleep(1000); await PageObjects.lens.closeDimensionEditor(); @@ -98,7 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discoverChart'); // check the query expect(await queryBar.getQueryString()).be.eql( - '( ( ip: "220.120.146.16" ) OR ( ip: "152.56.56.106" ) OR ( ip: "111.55.80.52" ) )' + '( ( ip: "86.252.46.140" ) OR ( ip: "155.34.86.215" ) OR ( ip: "133.198.170.210" ) )' ); const filterPills = await filterBar.getFiltersLabel(); expect(filterPills.length).to.be(1); From 6dbad1d087fdb464e042946bcb49b7d0b101ab33 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Thu, 24 Mar 2022 08:01:04 -0400 Subject: [PATCH 48/66] [Upgrade Assistant] Restrict UI to Kibana admins (#127922) (#128418) --- .../app/privileges.test.tsx | 52 +++++++++++++++++++ .../helpers/app_context.mock.ts | 9 +++- .../client_integration/helpers/index.ts | 1 + .../public/application/app.tsx | 35 +++++++++++-- .../public/shared_imports.ts | 1 + .../upgrade_assistant_security.ts | 6 +-- x-pack/test/functional/config.js | 4 +- 7 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx new file mode 100644 index 0000000000000..3ae0c013d694f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; + +import { AppDependencies } from '../../../public/types'; +import { setupEnvironment, kibanaVersion, getAppContextMock } from '../helpers'; +import { AppTestBed, setupAppPage } from './app.helpers'; + +describe('Privileges', () => { + let testBed: AppTestBed; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; + }); + + describe('when user is not a Kibana global admin', () => { + beforeEach(async () => { + const appContextMock = getAppContextMock(kibanaVersion) as unknown as AppDependencies; + const servicesMock = { + ...appContextMock.services, + core: { + ...appContextMock.services.core, + application: { + capabilities: { + spaces: { + manage: false, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setupAppPage(httpSetup, { services: servicesMock }); + }); + + testBed.component.update(); + }); + + test('renders not authorized message', () => { + const { exists } = testBed; + expect(exists('overview')).toBe(false); + expect(exists('missingKibanaPrivilegesMessage')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts index 3ddfeb3b057ea..3ceadecb208df 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts @@ -88,7 +88,14 @@ export const getAppContextMock = (kibanaVersion: SemVer) => ({ notifications: notificationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), history: scopedHistoryMock.create(), - application: applicationServiceMock.createStartContract(), + application: { + ...applicationServiceMock.createStartContract(), + capabilities: { + spaces: { + manage: true, + }, + }, + }, }, }, plugins: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index f70bfd00e9c07..4ae44f0027069 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { setupEnvironment, WithAppDependencies, kibanaVersion } from './setup_environment'; export { advanceTime } from './time_manipulation'; +export { getAppContextMock } from './app_context.mock'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 4b2b85638e8be..00c910fd648f7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -19,6 +19,7 @@ import { AuthorizationProvider, RedirectAppLinks, KibanaThemeProvider, + NotAuthorizedSection, } from '../shared_imports'; import { AppDependencies } from '../types'; import { AppContextProvider, useAppContext } from './app_context'; @@ -35,18 +36,46 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const AppHandlingClusterUpgradeState: React.FunctionComponent = () => { const { isReadOnlyMode, - services: { api }, + services: { api, core }, } = useAppContext(); - const [clusterUpgradeState, setClusterUpradeState] = + const missingManageSpacesPrivilege = core.application.capabilities.spaces.manage !== true; + + const [clusterUpgradeState, setClusterUpgradeState] = useState('isPreparingForUpgrade'); useEffect(() => { api.onClusterUpgradeStateChange((newClusterUpgradeState: ClusterUpgradeState) => { - setClusterUpradeState(newClusterUpgradeState); + setClusterUpgradeState(newClusterUpgradeState); }); }, [api]); + if (missingManageSpacesPrivilege) { + return ( + + + } + message={ + + } + /> + + ); + } + // Read-only mode will be enabled up until the last minor before the next major release if (isReadOnlyMode) { return ; diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 988bb1363398b..7d23f88a95c44 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -22,6 +22,7 @@ export { WithPrivileges, AuthorizationProvider, AuthorizationContext, + NotAuthorizedSection, } from '../../../../src/plugins/es_ui_shared/public/'; export { Storage } from '../../../../src/plugins/kibana_utils/public'; diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index a7368dfbedf07..a5b28a6bf6c06 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -56,11 +56,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); - it('should render the "Stack" section with Upgrde Assistant', async function () { + it('should render the "Stack" section with Upgrade Assistant', async function () { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ + expect(sections).to.have.length(5); + expect(sections[4]).to.eql({ sectionId: 'stack', sectionLinks: ['license_management', 'upgrade_assistant'], }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index b7774b463d058..c32d6f7304aea 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -464,9 +464,7 @@ export default async function ({ readConfigFile }) { }, kibana: [ { - feature: { - discover: ['read'], - }, + base: ['all'], spaces: ['*'], }, ], From 337fadfe291eced5b090f83605c856ed0233d557 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 24 Mar 2022 12:28:13 +0000 Subject: [PATCH 49/66] [ML] Transforms: Migrate IndexPattern service usage to DataView service (#128247) * [ML] Transforms: Migrate IndexPattern service usage to DataView service * [ML] Fix tests * [ML] Fix API delete_transforms tests * [ML] Edits from review --- .../transform/common/api_schemas/common.ts | 6 +- .../common/api_schemas/delete_transforms.ts | 4 +- .../transform/common/types/data_view.test.ts | 21 ++++++ .../types/{index_pattern.ts => data_view.ts} | 10 +-- .../common/types/index_pattern.test.ts | 21 ------ .../transform/common/types/transform.ts | 2 +- .../transform/public/app/common/data_grid.ts | 4 +- .../public/app/common/request.test.ts | 38 +++++----- .../transform/public/app/common/request.ts | 18 ++--- .../public/app/hooks/__mocks__/use_api.ts | 2 +- .../transform/public/app/hooks/use_api.ts | 4 +- .../public/app/hooks/use_delete_transform.tsx | 42 +++++------ .../public/app/hooks/use_index_data.test.tsx | 18 ++--- .../public/app/hooks/use_index_data.ts | 50 ++++++------- .../public/app/hooks/use_pivot_data.ts | 6 +- .../app/hooks/use_search_items/common.ts | 69 +++++++++--------- .../use_search_items/use_search_items.ts | 26 +++---- .../clone_transform_section.tsx | 6 +- .../source_search_bar/source_search_bar.tsx | 6 +- .../step_create/step_create_form.test.tsx | 4 +- .../step_create/step_create_form.tsx | 32 ++++----- .../apply_transform_config_to_define_state.ts | 10 +-- .../step_define/common/common.test.ts | 16 ++--- .../components/filter_agg_form.test.tsx | 16 ++--- .../filter_agg/components/filter_agg_form.tsx | 16 ++--- .../components/filter_term_form.tsx | 4 +- .../common/get_pivot_dropdown_options.ts | 8 +-- .../hooks/use_latest_function_config.ts | 22 +++--- .../step_define/hooks/use_pivot_config.ts | 6 +- .../step_define/hooks/use_search_bar.ts | 4 +- .../step_define/hooks/use_step_define_form.ts | 12 ++-- .../step_define/step_define_form.test.tsx | 8 +-- .../step_define/step_define_form.tsx | 17 ++--- .../step_define/step_define_summary.test.tsx | 6 +- .../step_define/step_define_summary.tsx | 6 +- .../components/step_details/common.ts | 10 +-- .../step_details/step_details_form.tsx | 72 +++++++++---------- .../step_details/step_details_summary.tsx | 10 +-- .../step_details/step_details_time_field.tsx | 14 ++-- .../components/wizard/wizard.tsx | 26 +++---- .../action_clone/use_clone_action.tsx | 20 +++--- .../action_delete/delete_action_modal.tsx | 20 +++--- .../action_delete/use_delete_action.tsx | 18 ++--- .../discover_action_name.test.tsx | 4 +- .../action_discover/discover_action_name.tsx | 10 +-- .../action_discover/use_action_discover.tsx | 43 ++++++----- .../action_edit/use_edit_action.tsx | 16 ++--- .../edit_transform_flyout.tsx | 9 +-- .../edit_transform_flyout_form.tsx | 18 ++--- .../expanded_row_preview_pane.tsx | 4 +- .../components/transform_list/use_actions.tsx | 2 +- .../transform_management_section.tsx | 2 +- .../public/app/services/es_index_service.ts | 6 +- .../server/routes/api/field_histograms.ts | 17 ++--- .../transform/server/routes/api/transforms.ts | 40 +++++------ .../apis/transform/delete_transforms.ts | 20 +++--- .../test/functional/apps/transform/cloning.ts | 4 +- .../apps/transform/creation_index_pattern.ts | 4 +- .../transform/creation_runtime_mappings.ts | 4 +- .../apps/transform/creation_saved_search.ts | 4 +- .../functional/services/transform/wizard.ts | 8 +-- 61 files changed, 451 insertions(+), 494 deletions(-) create mode 100644 x-pack/plugins/transform/common/types/data_view.test.ts rename x-pack/plugins/transform/common/types/{index_pattern.ts => data_view.ts} (61%) delete mode 100644 x-pack/plugins/transform/common/types/index_pattern.test.ts diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 285f3879681c7..4cd7d865a69f3 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -29,12 +29,12 @@ export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.WAITING), ]); -export const indexPatternTitleSchema = schema.object({ +export const dataViewTitleSchema = schema.object({ /** Title of the data view for which to return stats. */ - indexPatternTitle: schema.string(), + dataViewTitle: schema.string(), }); -export type IndexPatternTitleSchema = TypeOf; +export type DataViewTitleSchema = TypeOf; export const transformIdParamSchema = schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts index 05fefc278e350..e12c144b60af6 100644 --- a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts @@ -20,7 +20,7 @@ export const deleteTransformsRequestSchema = schema.object({ }) ), deleteDestIndex: schema.maybe(schema.boolean()), - deleteDestIndexPattern: schema.maybe(schema.boolean()), + deleteDestDataView: schema.maybe(schema.boolean()), forceDelete: schema.maybe(schema.boolean()), }); @@ -29,7 +29,7 @@ export type DeleteTransformsRequestSchema = TypeOf { + test('isDataView()', () => { + expect(isDataView(0)).toBe(false); + expect(isDataView('')).toBe(false); + expect(isDataView(null)).toBe(false); + expect(isDataView({})).toBe(false); + expect(isDataView({ attribute: 'value' })).toBe(false); + expect(isDataView({ fields: [], title: 'Data View Title', getComputedFields: () => {} })).toBe( + true + ); + }); +}); diff --git a/x-pack/plugins/transform/common/types/index_pattern.ts b/x-pack/plugins/transform/common/types/data_view.ts similarity index 61% rename from x-pack/plugins/transform/common/types/index_pattern.ts rename to x-pack/plugins/transform/common/types/data_view.ts index 0485de8982e1a..c09b84dea1e4e 100644 --- a/x-pack/plugins/transform/common/types/index_pattern.ts +++ b/x-pack/plugins/transform/common/types/data_view.ts @@ -5,18 +5,18 @@ * 2.0. */ -import type { IndexPattern } from '../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../src/plugins/data_views/common'; import { isPopulatedObject } from '../shared_imports'; -// Custom minimal type guard for IndexPattern to check against the attributes used in transforms code. -export function isIndexPattern(arg: any): arg is IndexPattern { +// Custom minimal type guard for DataView to check against the attributes used in transforms code. +export function isDataView(arg: any): arg is DataView { return ( isPopulatedObject(arg, ['title', 'fields']) && // `getComputedFields` is inherited, so it's not possible to // check with `hasOwnProperty` which is used by isPopulatedObject() - 'getComputedFields' in (arg as IndexPattern) && - typeof (arg as IndexPattern).getComputedFields === 'function' && + 'getComputedFields' in (arg as DataView) && + typeof (arg as DataView).getComputedFields === 'function' && typeof arg.title === 'string' && Array.isArray(arg.fields) ); diff --git a/x-pack/plugins/transform/common/types/index_pattern.test.ts b/x-pack/plugins/transform/common/types/index_pattern.test.ts deleted file mode 100644 index 57d57473d99de..0000000000000 --- a/x-pack/plugins/transform/common/types/index_pattern.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isIndexPattern } from './index_pattern'; - -describe('index_pattern', () => { - test('isIndexPattern()', () => { - expect(isIndexPattern(0)).toBe(false); - expect(isIndexPattern('')).toBe(false); - expect(isIndexPattern(null)).toBe(false); - expect(isIndexPattern({})).toBe(false); - expect(isIndexPattern({ attribute: 'value' })).toBe(false); - expect( - isIndexPattern({ fields: [], title: 'Data View Title', getComputedFields: () => {} }) - ).toBe(true); - }); -}); diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index 92ffc0b99bc3d..a196111bf6678 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -13,7 +13,7 @@ import type { PivotAggDict } from './pivot_aggs'; import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; -export type IndexPattern = string; +export type DataView = string; export type TransformId = string; /** diff --git a/x-pack/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts index 082e73651bb72..43d2b27f13cf9 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.ts @@ -15,8 +15,8 @@ export const getPivotPreviewDevConsoleStatement = (request: PostTransformsPrevie return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`; }; -export const getIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => { - return `GET ${indexPatternTitle}/_search\n${JSON.stringify( +export const getIndexDevConsoleStatement = (query: PivotQuery, dataViewTitle: string) => { + return `GET ${dataViewTitle}/_search\n${JSON.stringify( { query, }, diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index cd34b20cc87a6..f8c5a64099ba2 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -80,7 +80,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody()', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody('the-index-pattern-title', query, { + const request = getPreviewTransformRequestBody('the-data-view-title', query, { pivot: { aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, @@ -93,7 +93,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -101,16 +101,12 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with comma-separated index pattern', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody( - 'the-index-pattern-title,the-other-title', - query, - { - pivot: { - aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, - group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, - }, - } - ); + const request = getPreviewTransformRequestBody('the-data-view-title,the-other-title', query, { + pivot: { + aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, + group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, + }, + }); expect(request).toEqual({ pivot: { @@ -118,7 +114,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title', 'the-other-title'], + index: ['the-data-view-title', 'the-other-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -178,7 +174,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with missing_buckets config', () => { const query = getPivotQuery('the-query'); const request = getPreviewTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', query, getRequestPayload([aggsAvg], [{ ...groupByTerms, ...{ missing_bucket: true } }]) ); @@ -191,7 +187,7 @@ describe('Transform: Common', () => { }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -226,7 +222,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -243,7 +239,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -261,7 +257,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, }, }); @@ -305,7 +301,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -322,7 +318,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -340,7 +336,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, runtime_mappings: runtimeMappings, }, diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 36776759eb47a..0f94f82355fd2 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -8,7 +8,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { HttpFetchError } from '../../../../../../src/core/public'; -import type { IndexPattern } from '../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../src/plugins/data_views/public'; import type { PivotTransformPreviewRequestSchema, @@ -19,7 +19,7 @@ import type { } from '../../../common/api_schemas/transforms'; import { isPopulatedObject } from '../../../common/shared_imports'; import { DateHistogramAgg, HistogramAgg, TermsAgg } from '../../../common/types/pivot_group_by'; -import { isIndexPattern } from '../../../common/types/index_pattern'; +import { isDataView } from '../../../common/types/data_view'; import type { SavedSearchQuery } from '../hooks/use_search_items'; import type { StepDefineExposedState } from '../sections/create_transform/components/step_define'; @@ -78,14 +78,14 @@ export function isDefaultQuery(query: PivotQuery): boolean { } export function getCombinedRuntimeMappings( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): StepDefineExposedState['runtimeMappings'] | undefined { let combinedRuntimeMappings = {}; // And runtime field mappings defined by index pattern - if (isIndexPattern(indexPattern)) { - const computedFields = indexPattern.getComputedFields(); + if (isDataView(dataView)) { + const computedFields = dataView.getComputedFields(); if (computedFields?.runtimeFields !== undefined) { const ipRuntimeMappings = computedFields.runtimeFields; if (isPopulatedObject(ipRuntimeMappings)) { @@ -167,12 +167,12 @@ export const getRequestPayload = ( }; export function getPreviewTransformRequestBody( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], query: PivotQuery, partialRequest?: StepDefineExposedState['previewRequest'] | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): PostTransformsPreviewRequestSchema { - const index = indexPatternTitle.split(',').map((name: string) => name.trim()); + const index = dataViewTitle.split(',').map((name: string) => name.trim()); return { source: { @@ -199,12 +199,12 @@ export const getCreateTransformSettingsRequestBody = ( }; export const getCreateTransformRequestBody = ( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], pivotState: StepDefineExposedState, transformDetailsState: StepDetailsExposedState ): PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema => ({ ...getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, getPivotQuery(pivotState.searchQuery), pivotState.previewRequest, pivotState.runtimeMappings diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 979a98ececabb..cd46caf931e17 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -166,7 +166,7 @@ const apiFactory = () => ({ return Promise.resolve([]); }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 7119ad2719f5e..65c0d2050a5ed 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -226,14 +226,14 @@ export const useApi = () => { } }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'], samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE ): Promise { try { - return await http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + return await http.post(`${API_BASE_PATH}field_histograms/${dataViewTitle}`, { body: JSON.stringify({ query, fields, diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index ff93f027fc3a4..65a20f2d24ddf 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -30,24 +30,24 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const toastNotifications = useToastNotifications(); const [deleteDestIndex, setDeleteDestIndex] = useState(true); - const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); + const [deleteDataView, setDeleteDataView] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); - const [indexPatternExists, setIndexPatternExists] = useState(false); + const [dataViewExists, setDataViewExists] = useState(false); const [userCanDeleteDataView, setUserCanDeleteDataView] = useState(false); const toggleDeleteIndex = useCallback( () => setDeleteDestIndex(!deleteDestIndex), [deleteDestIndex] ); - const toggleDeleteIndexPattern = useCallback( - () => setDeleteIndexPattern(!deleteIndexPattern), - [deleteIndexPattern] + const toggleDeleteDataView = useCallback( + () => setDeleteDataView(!deleteDataView), + [deleteDataView] ); - const checkIndexPatternExists = useCallback( + const checkDataViewExists = useCallback( async (indexName: string) => { try { - if (await indexService.indexPatternExists(savedObjects.client, indexName)) { - setIndexPatternExists(true); + if (await indexService.dataViewExists(savedObjects.client, indexName)) { + setDataViewExists(true); } } catch (e) { const error = extractErrorMessage(e); @@ -77,7 +77,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { capabilities.indexPatterns.save === true; setUserCanDeleteDataView(canDeleteDataView); if (canDeleteDataView === false) { - setDeleteIndexPattern(false); + setDeleteDataView(false); } } catch (e) { toastNotifications.addDanger( @@ -100,20 +100,20 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const destinationIndex = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index; - checkIndexPatternExists(destinationIndex); + checkDataViewExists(destinationIndex); } else { - setIndexPatternExists(true); + setDataViewExists(true); } - }, [checkIndexPatternExists, checkUserIndexPermission, items]); + }, [checkDataViewExists, checkUserIndexPermission, items]); return { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, }; }; @@ -149,7 +149,7 @@ export const useDeleteTransforms = () => { const successCount: Record = { transformDeleted: 0, destIndexDeleted: 0, - destIndexPatternDeleted: 0, + destDataViewDeleted: 0, }; for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes @@ -179,7 +179,7 @@ export const useDeleteTransforms = () => { ) ); } - if (status.destIndexPatternDeleted?.success) { + if (status.destDataViewDeleted?.success) { toastNotifications.addSuccess( i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewSuccessMessage', @@ -238,8 +238,8 @@ export const useDeleteTransforms = () => { }); } - if (status.destIndexPatternDeleted?.error) { - const error = status.destIndexPatternDeleted.error.reason; + if (status.destDataViewDeleted?.error) { + const error = status.destDataViewDeleted.error.reason; toastNotifications.addDanger({ title: i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewErrorMessage', @@ -283,12 +283,12 @@ export const useDeleteTransforms = () => { }) ); } - if (successCount.destIndexPatternDeleted > 0) { + if (successCount.destDataViewDeleted > 0) { toastNotifications.addSuccess( i18n.translate('xpack.transform.transformList.bulkDeleteDestDataViewSuccessMessage', { defaultMessage: 'Successfully deleted {count} destination data {count, plural, one {view} other {views}}.', - values: { count: successCount.destIndexPatternDeleted }, + values: { count: successCount.destDataViewDeleted }, }) ); } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx index 74d5167c12697..d74c11cbaf607 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx @@ -46,7 +46,7 @@ const runtimeMappings = { }; describe('Transform: useIndexData()', () => { - test('indexPattern set triggers loading', async () => { + test('dataView set triggers loading', async () => { const mlShared = await getMlSharedImports(); const wrapper: FC = ({ children }) => ( @@ -61,7 +61,7 @@ describe('Transform: useIndexData()', () => { id: 'the-id', title: 'the-title', fields: [], - } as unknown as SearchItems['indexPattern'], + } as unknown as SearchItems['dataView'], query, runtimeMappings ), @@ -81,10 +81,10 @@ describe('Transform: useIndexData()', () => { describe('Transform: with useIndexData()', () => { test('Minimal initialization, no cross cluster search warning.', async () => { // Arrange - const indexPattern = { - title: 'the-index-pattern-title', + const dataView = { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -93,7 +93,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', @@ -124,10 +124,10 @@ describe('Transform: with useIndexData()', () => { test('Cross-cluster search warning', async () => { // Arrange - const indexPattern = { + const dataView = { title: 'remote:the-index-pattern-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -136,7 +136,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 1d73413b3e386..678ec6d291ceb 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -31,7 +31,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { isRuntimeMappings } from '../../../common/shared_imports'; export const useIndexData = ( - indexPattern: SearchItems['indexPattern'], + dataView: SearchItems['dataView'], query: PivotQuery, combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'] ): UseIndexDataReturnType => { @@ -51,7 +51,7 @@ export const useIndexData = ( }, } = useAppDependencies(); - const [indexPatternFields, setIndexPatternFields] = useState(); + const [dataViewFields, setDataViewFields] = useState(); // Fetch 500 random documents to determine populated fields. // This is a workaround to avoid passing potentially thousands of unpopulated fields @@ -62,7 +62,7 @@ export const useIndexData = ( setStatus(INDEX_STATUS.LOADING); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -84,21 +84,21 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); // Get all field names for each returned doc and flatten it // to a list of unique field names used across all docs. - const allKibanaIndexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allKibanaIndexPatternFields.includes(d)) + .filter((d) => allDataViewFields.includes(d)) .sort(); setCcsWarning(isCrossClusterSearch && isMissingFields); setStatus(INDEX_STATUS.LOADED); - setIndexPatternFields(populatedFields); + setDataViewFields(populatedFields); }; useEffect(() => { @@ -107,7 +107,7 @@ export const useIndexData = ( }, []); const columns: EuiDataGridColumn[] = useMemo(() => { - if (typeof indexPatternFields === 'undefined') { + if (typeof dataViewFields === 'undefined') { return []; } @@ -124,8 +124,8 @@ export const useIndexData = ( } // Combine the runtime field that are defined from API field - indexPatternFields.forEach((id) => { - const field = indexPattern.fields.getByName(id); + dataViewFields.forEach((id) => { + const field = dataView.fields.getByName(id); if (!field?.runtimeField) { const schema = getDataGridSchemaFromKibanaFieldType(field); result.push({ id, schema }); @@ -134,8 +134,8 @@ export const useIndexData = ( return result.sort((a, b) => a.id.localeCompare(b.id)); }, [ - indexPatternFields, - indexPattern.fields, + dataViewFields, + dataView.fields, combinedRuntimeMappings, getDataGridSchemaFromESFieldType, getDataGridSchemaFromKibanaFieldType, @@ -176,7 +176,7 @@ export const useIndexData = ( }, {} as EsSorting); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -198,7 +198,7 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); @@ -215,16 +215,16 @@ export const useIndexData = ( }; const fetchColumnChartsData = async function () { - const allIndexPatternFieldNames = new Set(indexPattern.fields.map((f) => f.name)); + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); const columnChartsData = await api.getHistogramsForFields( - indexPattern.title, + dataView.title, columns .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) .map((cT) => { // If a column field name has a corresponding keyword field, // fetch the keyword field instead to be able to do aggregations. const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allIndexPatternFieldNames) + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) ? { fieldName: `${fieldName}.keyword`, type: getFieldType(undefined), @@ -247,7 +247,7 @@ export const useIndexData = ( // revert field names with `.keyword` used to do aggregations to their original column name columnChartsData.map((d) => ({ ...d, - ...(isKeywordDuplicate(d.id, allIndexPatternFieldNames) + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) ? { id: removeKeywordPostfix(d.id) } : {}), })) @@ -259,15 +259,9 @@ export const useIndexData = ( // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify([ - query, - pagination, - sortingColumns, - indexPatternFields, - combinedRuntimeMappings, - ]), + JSON.stringify([query, pagination, sortingColumns, dataViewFields, combinedRuntimeMappings]), ]); useEffect(() => { @@ -278,12 +272,12 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chartsVisible, - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify([query, dataGrid.visibleColumns, combinedRuntimeMappings]), ]); - const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems); + const renderCellValue = useRenderCellValue(dataView, pagination, tableItems); return { ...dataGrid, diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 01cb39ac87fa8..d30237abcdb3f 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -96,7 +96,7 @@ export function getCombinedProperties( } export const usePivotData = ( - indexPatternTitle: SearchItems['indexPattern']['title'], + dataViewTitle: SearchItems['dataView']['title'], query: PivotQuery, validationStatus: StepDefineExposedState['validationStatus'], requestPayload: StepDefineExposedState['previewRequest'], @@ -165,7 +165,7 @@ export const usePivotData = ( setStatus(INDEX_STATUS.LOADING); const previewRequest = getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, query, requestPayload, combinedRuntimeMappings @@ -233,7 +233,7 @@ export const usePivotData = ( getPreviewData(); // custom comparison /* eslint-disable react-hooks/exhaustive-deps */ - }, [indexPatternTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); + }, [dataViewTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); if (sortingColumns.length > 0) { const sortingColumnsWithTypes = sortingColumns.map((c) => ({ diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 19ff063d11acf..910960cb24eea 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -7,45 +7,45 @@ import { buildEsQuery } from '@kbn/es-query'; import { SavedObjectsClientContract, SimpleSavedObject, IUiSettingsClient } from 'src/core/public'; +import { getEsQueryConfig } from '../../../../../../../src/plugins/data/public'; import { - IndexPattern, - getEsQueryConfig, - IndexPatternsContract, - IndexPatternAttributes, -} from '../../../../../../../src/plugins/data/public'; + DataView, + DataViewAttributes, + DataViewsContract, +} from '../../../../../../../src/plugins/data_views/public'; import { matchAllQuery } from '../../common'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; export type SavedSearchQuery = object; -type IndexPatternId = string; +type DataViewId = string; -let indexPatternCache: Array>> = []; -let fullIndexPatterns; -let currentIndexPattern = null; +let dataViewCache: Array>> = []; +let fullDataViews; +let currentDataView = null; -export let refreshIndexPatterns: () => Promise; +export let refreshDataViews: () => Promise; -export function loadIndexPatterns( +export function loadDataViews( savedObjectsClient: SavedObjectsClientContract, - indexPatterns: IndexPatternsContract + dataViews: DataViewsContract ) { - fullIndexPatterns = indexPatterns; + fullDataViews = dataViews; return savedObjectsClient - .find({ + .find({ type: 'index-pattern', fields: ['id', 'title', 'type', 'fields'], perPage: 10000, }) .then((response) => { - indexPatternCache = response.savedObjects; + dataViewCache = response.savedObjects; - if (refreshIndexPatterns === null) { - refreshIndexPatterns = () => { + if (refreshDataViews === null) { + refreshDataViews = () => { return new Promise((resolve, reject) => { - loadIndexPatterns(savedObjectsClient, indexPatterns) + loadDataViews(savedObjectsClient, dataViews) .then((resp) => { resolve(resp); }) @@ -56,27 +56,24 @@ export function loadIndexPatterns( }; } - return indexPatternCache; + return dataViewCache; }); } -export function getIndexPatternIdByTitle(indexPatternTitle: string): string | undefined { - return indexPatternCache.find((d) => d?.attributes?.title === indexPatternTitle)?.id; +export function getDataViewIdByTitle(dataViewTitle: string): string | undefined { + return dataViewCache.find((d) => d?.attributes?.title === dataViewTitle)?.id; } type CombinedQuery = Record<'bool', any> | object; -export function loadCurrentIndexPattern( - indexPatterns: IndexPatternsContract, - indexPatternId: IndexPatternId -) { - fullIndexPatterns = indexPatterns; - currentIndexPattern = fullIndexPatterns.get(indexPatternId); - return currentIndexPattern; +export function loadCurrentDataView(dataViews: DataViewsContract, dataViewId: DataViewId) { + fullDataViews = dataViews; + currentDataView = fullDataViews.get(dataViewId); + return currentDataView; } export interface SearchItems { - indexPattern: IndexPattern; + dataView: DataView; savedSearch: any; query: any; combinedQuery: CombinedQuery; @@ -84,7 +81,7 @@ export interface SearchItems { // Helper for creating the items used for searching and job creation. export function createSearchItems( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, savedSearch: any, config: IUiSettingsClient ): SearchItems { @@ -103,9 +100,9 @@ export function createSearchItems( }, }; - if (!isIndexPattern(indexPattern) && savedSearch !== null && savedSearch.id !== undefined) { + if (!isDataView(dataView) && savedSearch !== null && savedSearch.id !== undefined) { const searchSource = savedSearch.searchSource; - indexPattern = searchSource.getField('index') as IndexPattern; + dataView = searchSource.getField('index') as DataView; query = searchSource.getField('query'); const fs = searchSource.getField('filter'); @@ -113,15 +110,15 @@ export function createSearchItems( const filters = fs.length ? fs : []; const esQueryConfigs = getEsQueryConfig(config); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } - if (!isIndexPattern(indexPattern)) { + if (!isDataView(dataView)) { throw new Error('Data view is not defined.'); } return { - indexPattern, + dataView, savedSearch, query, combinedQuery, diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 754cc24b65fec..76fdc77c523e4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../shared_imports'; @@ -17,9 +17,9 @@ import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, - getIndexPatternIdByTitle, - loadCurrentIndexPattern, - loadIndexPatterns, + getDataViewIdByTitle, + loadCurrentDataView, + loadDataViews, SearchItems, } from './common'; @@ -28,22 +28,22 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [error, setError] = useState(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const uiSettings = appDeps.uiSettings; const savedObjectsClient = appDeps.savedObjects.client; const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); - let fetchedIndexPattern; + let fetchedDataView; let fetchedSavedSearch; try { - fetchedIndexPattern = await loadCurrentIndexPattern(indexPatterns, id); + fetchedDataView = await loadCurrentDataView(dataViews, id); } catch (e) { - // Just let fetchedIndexPattern stay undefined in case it doesn't exist. + // Just let fetchedDataView stay undefined in case it doesn't exist. } try { @@ -61,7 +61,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - if (!isIndexPattern(fetchedIndexPattern) && fetchedSavedSearch === undefined) { + if (!isDataView(fetchedDataView) && fetchedSavedSearch === undefined) { setError( i18n.translate('xpack.transform.searchItems.errorInitializationTitle', { defaultMessage: `An error occurred initializing the Kibana data view or saved search.`, @@ -70,7 +70,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return; } - setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); + setSearchItems(createSearchItems(fetchedDataView, fetchedSavedSearch, uiSettings)); setError(undefined); } @@ -84,8 +84,8 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return { error, - getIndexPatternIdByTitle, - loadIndexPatterns, + getDataViewIdByTitle, + loadDataViews, searchItems, setSavedObjectId, }; diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c84f7cb97c959..dceb585c5c190 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -36,7 +36,7 @@ import { overrideTransformForCloning } from '../../common/transform'; type Props = RouteComponentProps<{ transformId: string }>; export const CloneTransformSection: FC = ({ match, location }) => { - const { indexPatternId }: Record = parse(location.search, { + const { dataViewId }: Record = parse(location.search, { sort: false, }); // Set breadcrumb and page title @@ -73,7 +73,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { } try { - if (indexPatternId === undefined) { + if (dataViewId === undefined) { throw new Error( i18n.translate('xpack.transform.clone.fetchErrorPromptText', { defaultMessage: 'Could not fetch the Kibana data view ID.', @@ -81,7 +81,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { ); } - setSavedObjectId(indexPatternId); + setSavedObjectId(dataViewId); setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0])); setErrorMessage(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx index 5006b898f3bb3..b20909ec9e128 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx @@ -18,10 +18,10 @@ import { SearchItems } from '../../../../hooks/use_search_items'; import { StepDefineFormHook, QUERY_LANGUAGE_KUERY } from '../step_define'; interface SourceSearchBarProps { - indexPattern: SearchItems['indexPattern']; + dataView: SearchItems['dataView']; searchBar: StepDefineFormHook['searchBar']; } -export const SourceSearchBar: FC = ({ indexPattern, searchBar }) => { +export const SourceSearchBar: FC = ({ dataView, searchBar }) => { const { actions: { searchChangeHandler, searchSubmitHandler, setErrorMessage }, state: { errorMessage, searchInput }, @@ -35,7 +35,7 @@ export const SourceSearchBar: FC = ({ indexPattern, search ', () => { test('Minimal initialization', () => { // Arrange const props: StepCreateFormProps = { - createIndexPattern: false, + createDataView: false, transformId: 'the-transform-id', transformConfig: { dest: { @@ -31,7 +31,7 @@ describe('Transform: ', () => { index: 'the-source-index', }, }, - overrides: { created: false, started: false, indexPatternId: undefined }, + overrides: { created: false, started: false, dataViewId: undefined }, onChange() {}, }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 42b50e6ef4c1f..bac7754842510 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -56,19 +56,19 @@ import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting export interface StepDetailsExposedState { created: boolean; started: boolean; - indexPatternId: string | undefined; + dataViewId: string | undefined; } export function getDefaultStepCreateState(): StepDetailsExposedState { return { created: false, started: false, - indexPatternId: undefined, + dataViewId: undefined, }; } export interface StepCreateFormProps { - createIndexPattern: boolean; + createDataView: boolean; transformId: string; transformConfig: PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema; overrides: StepDetailsExposedState; @@ -77,7 +77,7 @@ export interface StepCreateFormProps { } export const StepCreateForm: FC = React.memo( - ({ createIndexPattern, transformConfig, transformId, onChange, overrides, timeFieldName }) => { + ({ createDataView, transformConfig, transformId, onChange, overrides, timeFieldName }) => { const defaults = { ...getDefaultStepCreateState(), ...overrides }; const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false); @@ -86,7 +86,7 @@ export const StepCreateForm: FC = React.memo( const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); + const [dataViewId, setDataViewId] = useState(defaults.dataViewId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); @@ -94,14 +94,14 @@ export const StepCreateForm: FC = React.memo( const deps = useAppDependencies(); const { share } = deps; - const indexPatterns = deps.data.indexPatterns; + const dataViews = deps.data.dataViews; const toastNotifications = useToastNotifications(); const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { let unmounted = false; - onChange({ created, started, indexPatternId }); + onChange({ created, started, dataViewId }); const getDiscoverUrl = async (): Promise => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); @@ -109,7 +109,7 @@ export const StepCreateForm: FC = React.memo( if (!locator) return; const discoverUrl = await locator.getUrl({ - indexPatternId, + indexPatternId: dataViewId, }); if (!unmounted) { @@ -117,7 +117,7 @@ export const StepCreateForm: FC = React.memo( } }; - if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + if (started === true && dataViewId !== undefined && isDiscoverAvailable) { getDiscoverUrl(); } @@ -126,7 +126,7 @@ export const StepCreateForm: FC = React.memo( }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps - }, [created, started, indexPatternId]); + }, [created, started, dataViewId]); const { overlays, theme } = useAppDependencies(); const api = useApi(); @@ -174,8 +174,8 @@ export const StepCreateForm: FC = React.memo( setCreated(true); setLoading(false); - if (createIndexPattern) { - createKibanaIndexPattern(); + if (createDataView) { + createKibanaDataView(); } return true; @@ -228,7 +228,7 @@ export const StepCreateForm: FC = React.memo( } } - const createKibanaIndexPattern = async () => { + const createKibanaDataView = async () => { setLoading(true); const dataViewName = transformConfig.dest.index; const runtimeMappings = transformConfig.source.runtime_mappings as Record< @@ -237,7 +237,7 @@ export const StepCreateForm: FC = React.memo( >; try { - const newIndexPattern = await indexPatterns.createAndSave( + const newDataView = await dataViews.createAndSave( { title: dataViewName, timeFieldName, @@ -256,7 +256,7 @@ export const StepCreateForm: FC = React.memo( }) ); - setIndexPatternId(newIndexPattern.id); + setDataViewId(newDataView.id); setLoading(false); return true; } catch (e) { @@ -529,7 +529,7 @@ export const StepCreateForm: FC = React.memo( data-test-subj="transformWizardCardManagement" />
    - {started === true && createIndexPattern === true && indexPatternId === undefined && ( + {started === true && createDataView === true && dataViewId === undefined && ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index 497f37036725c..e0c8b30a93998 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -35,11 +35,11 @@ import { getCombinedRuntimeMappings } from '../../../../../common/request'; export function applyTransformConfigToDefineState( state: StepDefineExposedState, transformConfig?: TransformBaseConfig, - indexPattern?: StepDefineFormProps['searchItems']['indexPattern'] + dataView?: StepDefineFormProps['searchItems']['dataView'] ): StepDefineExposedState { // apply runtime fields from both the index pattern and inline configurations state.runtimeMappings = getCombinedRuntimeMappings( - indexPattern, + dataView, transformConfig?.source?.runtime_mappings ); @@ -88,12 +88,12 @@ export function applyTransformConfigToDefineState( state.latestConfig = { unique_key: transformConfig.latest.unique_key.map((v) => ({ value: v, - label: indexPattern ? indexPattern.fields.find((f) => f.name === v)?.displayName ?? v : v, + label: dataView ? dataView.fields.find((f) => f.name === v)?.displayName ?? v : v, })), sort: { value: transformConfig.latest.sort, - label: indexPattern - ? indexPattern.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? + label: dataView + ? dataView.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? transformConfig.latest.sort : transformConfig.latest.sort, }, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 9b8dcc1a623e3..61081e7858b27 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -6,18 +6,18 @@ */ import { getPivotDropdownOptions } from '../common'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { FilterAggForm } from './filter_agg/components'; import type { RuntimeField } from '../../../../../../../../../../src/plugins/data/common'; describe('Transform: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { - // The field name includes the characters []> as well as a leading and ending space charcter + // The field name includes the characters []> as well as a leading and ending space character // which cannot be used for aggregation names. The test results verifies that the characters // should still be present in field and dropDownName values, but should be stripped for aggName values. - const indexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', + const dataView = { + id: 'the-data-view-id', + title: 'the-data-view-title', fields: [ { name: ' the-f[i]e>ld ', @@ -27,9 +27,9 @@ describe('Transform: Define Pivot Common', () => { searchable: true, }, ], - } as IndexPattern; + } as DataView; - const options = getPivotDropdownOptions(indexPattern); + const options = getPivotDropdownOptions(dataView); expect(options).toMatchObject({ aggOptions: [ @@ -120,7 +120,7 @@ describe('Transform: Define Pivot Common', () => { }, } as RuntimeField, }; - const optionsWithRuntimeFields = getPivotDropdownOptions(indexPattern, runtimeMappings); + const optionsWithRuntimeFields = getPivotDropdownOptions(dataView, runtimeMappings); expect(optionsWithRuntimeFields).toMatchObject({ aggOptions: [ { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx index 8c3c649749c2f..745cd81908ac8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -14,7 +14,7 @@ import { KBN_FIELD_TYPES, RuntimeField, } from '../../../../../../../../../../../../src/plugins/data/common'; -import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../../../src/plugins/data_views/public'; import { FilterTermForm } from './filter_term_form'; describe('FilterAggForm', () => { @@ -27,7 +27,7 @@ describe('FilterAggForm', () => { } as RuntimeField, }; - const indexPattern = { + const dataView = { fields: { getByName: jest.fn((fieldName: string) => { if (fieldName === 'test_text_field') { @@ -42,14 +42,14 @@ describe('FilterAggForm', () => { } }), }, - } as unknown as IndexPattern; + } as unknown as DataView; test('should render only select dropdown on empty configuration', async () => { const onChange = jest.fn(); const { getByLabelText, findByTestId, container } = render( - + @@ -74,7 +74,7 @@ describe('FilterAggForm', () => { const { findByTestId } = render( - + @@ -102,7 +102,7 @@ describe('FilterAggForm', () => { const { rerender, findByTestId } = render( - + @@ -111,7 +111,7 @@ describe('FilterAggForm', () => { // re-render the same component with different props rerender( - + @@ -139,7 +139,7 @@ describe('FilterAggForm', () => { const { findByTestId, container } = render( - + { - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const filterAggsOptions = useMemo( - () => getSupportedFilterAggs(selectedField, indexPattern!, runtimeMappings), - [indexPattern, selectedField, runtimeMappings] + () => getSupportedFilterAggs(selectedField, dataView!, runtimeMappings), + [dataView, selectedField, runtimeMappings] ); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 2d24d07fd7019..11f9dadbb359c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -30,7 +30,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm selectedField, }) => { const api = useApi(); - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); @@ -40,7 +40,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm const fetchOptions = useCallback( debounce(async (searchValue: string) => { const esSearchRequest = { - index: indexPattern!.title, + index: dataView!.title, body: { ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), query: { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index b17f30d115f4a..5c4ff5a53f724 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -8,9 +8,9 @@ import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ES_FIELD_TYPES, - IndexPattern, KBN_FIELD_TYPES, } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { getNestedProperty } from '../../../../../../../common/utils/object_utils'; import { removeKeywordPostfix } from '../../../../../../../common/utils/field_utils'; @@ -58,7 +58,7 @@ export function getKibanaFieldTypeFromEsType(type: string): KBN_FIELD_TYPES { } export function getPivotDropdownOptions( - indexPattern: IndexPattern, + dataView: DataView, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { // The available group by options @@ -70,7 +70,7 @@ export function getPivotDropdownOptions( const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; - const indexPatternFields = indexPattern.fields + const dataViewFields = dataView.fields .filter( (field) => field.aggregatable === true && @@ -93,7 +93,7 @@ export function getPivotDropdownOptions( const sortByLabel = (a: Field, b: Field) => a.name.localeCompare(b.name); - const combinedFields = [...indexPatternFields, ...runtimeFields].sort(sortByLabel); + const combinedFields = [...dataViewFields, ...runtimeFields].sort(sortByLabel); combinedFields.forEach((field) => { const rawFieldName = field.name; const displayFieldName = removeKeywordPostfix(rawFieldName); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts index d6473abb04702..46d5d1b562a84 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts @@ -30,18 +30,18 @@ export const latestConfigMapper = { /** * Provides available options for unique_key and sort fields - * @param indexPattern + * @param dataView * @param aggConfigs * @param runtimeMappings */ function getOptions( - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], aggConfigs: AggConfigs, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { const aggConfig = aggConfigs.aggs[0]; const param = aggConfig.type.params.find((p) => p.type === 'field'); - const filteredIndexPatternFields = param + const filteredDataViewFields = param ? (param as unknown as FieldParamType) .getAvailableFields(aggConfig) // runtimeMappings may already include runtime fields defined by the data view @@ -54,7 +54,7 @@ function getOptions( ? Object.keys(runtimeMappings).map((k) => ({ label: k, value: k })) : []; - const uniqueKeyOptions: Array> = filteredIndexPatternFields + const uniqueKeyOptions: Array> = filteredDataViewFields .filter((v) => !ignoreFieldNames.has(v.name)) .map((v) => ({ label: v.displayName, @@ -70,7 +70,7 @@ function getOptions( })) : []; - const indexPatternFieldsSortOptions: Array> = indexPattern.fields + const dataViewFieldsSortOptions: Array> = dataView.fields // The backend API for `latest` allows all field types for sort but the UI will be limited to `date`. .filter((v) => !ignoreFieldNames.has(v.name) && v.sortable && v.type === 'date') .map((v) => ({ @@ -83,9 +83,7 @@ function getOptions( return { uniqueKeyOptions: [...uniqueKeyOptions, ...runtimeFieldsOptions].sort(sortByLabel), - sortFieldOptions: [...indexPatternFieldsSortOptions, ...runtimeFieldsSortOptions].sort( - sortByLabel - ), + sortFieldOptions: [...dataViewFieldsSortOptions, ...runtimeFieldsSortOptions].sort(sortByLabel), }; } @@ -112,7 +110,7 @@ export function validateLatestConfig(config?: LatestFunctionConfig) { export function useLatestFunctionConfig( defaults: StepDefineExposedState['latestConfig'], - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], runtimeMappings: StepDefineExposedState['runtimeMappings'] ): { config: LatestFunctionConfigUI; @@ -130,9 +128,9 @@ export function useLatestFunctionConfig( const { data } = useAppDependencies(); const { uniqueKeyOptions, sortFieldOptions } = useMemo(() => { - const aggConfigs = data.search.aggs.createAggConfigs(indexPattern, [{ type: 'terms' }]); - return getOptions(indexPattern, aggConfigs, runtimeMappings); - }, [indexPattern, data.search.aggs, runtimeMappings]); + const aggConfigs = data.search.aggs.createAggConfigs(dataView, [{ type: 'terms' }]); + return getOptions(dataView, aggConfigs, runtimeMappings); + }, [dataView, data.search.aggs, runtimeMappings]); const updateLatestFunctionConfig = useCallback( (update) => diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 2415f04c220a6..c16270a6a2dca 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -100,13 +100,13 @@ function getRootAggregation(item: PivotAggsConfig) { export const usePivotConfig = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { const toastNotifications = useToastNotifications(); const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( - () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), - [defaults.runtimeMappings, indexPattern] + () => getPivotDropdownOptions(dataView, defaults.runtimeMappings), + [defaults.runtimeMappings, dataView] ); // The list of selected aggregations diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts index be6104d393d3f..b8c818720f0a9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts @@ -24,7 +24,7 @@ import { StepDefineFormProps } from '../step_define_form'; export const useSearchBar = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ @@ -53,7 +53,7 @@ export const useSearchBar = ( switch (query.language) { case QUERY_LANGUAGE_KUERY: setSearchQuery( - toElasticsearchQuery(fromKueryExpression(query.query as string), indexPattern) + toElasticsearchQuery(fromKueryExpression(query.query as string), dataView) ); return; case QUERY_LANGUAGE_LUCENE: diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index b56df5e395c88..f4c396808e294 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -25,21 +25,21 @@ export type StepDefineFormHook = ReturnType; export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefineFormProps) => { const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const [transformFunction, setTransformFunction] = useState(defaults.transformFunction); - const searchBar = useSearchBar(defaults, indexPattern); - const pivotConfig = usePivotConfig(defaults, indexPattern); + const searchBar = useSearchBar(defaults, dataView); + const pivotConfig = usePivotConfig(defaults, dataView); const latestFunctionConfig = useLatestFunctionConfig( defaults.latestConfig, - indexPattern, + dataView, defaults?.runtimeMappings ); const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, defaults?.runtimeMappings @@ -58,7 +58,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi const runtimeMappings = runtimeMappingsEditor.state.runtimeMappings; if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { const previewRequestUpdate = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, runtimeMappings diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 6e80b6162048e..054deb23eac50 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -57,10 +57,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; // mock services for QueryStringInput @@ -84,7 +84,7 @@ describe('Transform: ', () => { // Act // Assert expect(getByText('Data view')).toBeInTheDocument(); - expect(getByText(searchItems.indexPattern.title)).toBeInTheDocument(); + expect(getByText(searchItems.dataView.title)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 8d023e2ae430d..32bc4023f06f1 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -67,7 +67,7 @@ export interface StepDefineFormProps { export const StepDefineForm: FC = React.memo((props) => { const { searchItems } = props; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const { ml: { DataGrid }, } = useAppDependencies(); @@ -88,7 +88,7 @@ export const StepDefineForm: FC = React.memo((props) => { const indexPreviewProps = { ...useIndexData( - indexPattern, + dataView, stepDefineForm.searchBar.state.pivotQuery, stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ), @@ -101,7 +101,7 @@ export const StepDefineForm: FC = React.memo((props) => { : stepDefineForm.latestFunctionConfig; const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, pivotQuery, stepDefineForm.transformFunction === TRANSFORM_FUNCTION.PIVOT ? stepDefineForm.pivotConfig.state.requestPayload @@ -109,7 +109,7 @@ export const StepDefineForm: FC = React.memo((props) => { stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ); - const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, indexPattern.title); + const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, dataView.title); const copyToClipboardSourceDescription = i18n.translate( 'xpack.transform.indexPreview.copyClipboardTooltip', { @@ -127,7 +127,7 @@ export const StepDefineForm: FC = React.memo((props) => { const pivotPreviewProps = { ...usePivotData( - indexPattern.title, + dataView.title, pivotQuery, validationStatus, requestPayload, @@ -211,7 +211,7 @@ export const StepDefineForm: FC = React.memo((props) => { defaultMessage: 'Data view', })} > - {indexPattern.title} + {dataView.title} )} @@ -233,10 +233,7 @@ export const StepDefineForm: FC = React.memo((props) => { {searchItems.savedSearch === undefined && ( <> {!isAdvancedSourceEditorEnabled && ( - + )} {isAdvancedSourceEditorEnabled && } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 1e3fa2026061b..1b2d5872e53b6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -33,10 +33,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 2abb3f4c4cda8..2bae20da65067 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -56,14 +56,14 @@ export const StepDefineSummary: FC = ({ const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, runtimeMappings ); const pivotPreviewProps = usePivotData( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, validationStatus, partialPreviewRequest, @@ -92,7 +92,7 @@ export const StepDefineSummary: FC = ({ defaultMessage: 'Data view', })} > - {searchItems.indexPattern.title} + {searchItems.dataView.title} {typeof searchString === 'string' && ( ; } @@ -40,7 +40,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { return { continuousModeDateField: '', continuousModeDelay: defaultContinuousModeDelay, - createIndexPattern: true, + createDataView: true, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -53,7 +53,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { destinationIngestPipeline: '', touched: false, valid: false, - indexPatternTimeField: undefined, + dataViewTimeField: undefined, }; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 75ed5c10f0483..aa08049ac9d64 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -49,7 +49,7 @@ import { getPreviewTransformRequestBody, isTransformIdValid, } from '../../../../common'; -import { EsIndexName, IndexPatternTitle } from './common'; +import { EsIndexName, DataViewTitle } from './common'; import { continuousModeDelayValidator, retentionPolicyMaxAgeValidator, @@ -99,14 +99,12 @@ export const StepDetailsForm: FC = React.memo( ); // Index pattern state - const [indexPatternTitles, setIndexPatternTitles] = useState([]); - const [createIndexPattern, setCreateIndexPattern] = useState( - canCreateDataView === false ? false : defaults.createIndexPattern + const [dataViewTitles, setDataViewTitles] = useState([]); + const [createDataView, setCreateDataView] = useState( + canCreateDataView === false ? false : defaults.createDataView ); - const [indexPatternAvailableTimeFields, setIndexPatternAvailableTimeFields] = useState< - string[] - >([]); - const [indexPatternTimeField, setIndexPatternTimeField] = useState(); + const [dataViewAvailableTimeFields, setDataViewAvailableTimeFields] = useState([]); + const [dataViewTimeField, setDataViewTimeField] = useState(); const onTimeFieldChanged = React.useCallback( (e: React.ChangeEvent) => { @@ -117,11 +115,11 @@ export const StepDetailsForm: FC = React.memo( } // Find the time field based on the selected value // this is to account for undefined when user chooses not to use a date field - const timeField = indexPatternAvailableTimeFields.find((col) => col === value); + const timeField = dataViewAvailableTimeFields.find((col) => col === value); - setIndexPatternTimeField(timeField); + setDataViewTimeField(timeField); }, - [setIndexPatternTimeField, indexPatternAvailableTimeFields] + [setDataViewTimeField, dataViewAvailableTimeFields] ); const { overlays, theme } = useAppDependencies(); @@ -134,7 +132,7 @@ export const StepDetailsForm: FC = React.memo( const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState; const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, stepDefineState.runtimeMappings @@ -148,8 +146,8 @@ export const StepDetailsForm: FC = React.memo( (col) => properties[col].type === 'date' ); - setIndexPatternAvailableTimeFields(timeFields); - setIndexPatternTimeField(timeFields[0]); + setDataViewAvailableTimeFields(timeFields); + setDataViewTimeField(timeFields[0]); } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { @@ -228,7 +226,7 @@ export const StepDetailsForm: FC = React.memo( } try { - setIndexPatternTitles(await deps.data.indexPatterns.getTitles()); + setDataViewTitles(await deps.data.dataViews.getTitles()); } catch (e) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', { @@ -245,7 +243,7 @@ export const StepDetailsForm: FC = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const dateFieldNames = searchItems.indexPattern.fields + const dateFieldNames = searchItems.dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -291,7 +289,7 @@ export const StepDetailsForm: FC = React.memo( const indexNameExists = indexNames.some((name) => destinationIndex === name); const indexNameEmpty = destinationIndex === ''; const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some((name) => destinationIndex === name); + const dataViewTitleExists = dataViewTitles.some((name) => destinationIndex === name); const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency); const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency); @@ -313,7 +311,7 @@ export const StepDetailsForm: FC = React.memo( isTransformSettingsMaxPageSearchSizeValid && !indexNameEmpty && indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && + (!dataViewTitleExists || !createDataView) && (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)) && (!isRetentionPolicyAvailable || !isRetentionPolicyEnabled || @@ -327,7 +325,7 @@ export const StepDetailsForm: FC = React.memo( onChange({ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -341,7 +339,7 @@ export const StepDetailsForm: FC = React.memo( destinationIngestPipeline, touched: true, valid, - indexPatternTimeField, + dataViewTimeField, _meta: defaults._meta, }); // custom comparison @@ -349,7 +347,7 @@ export const StepDetailsForm: FC = React.memo( }, [ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -361,7 +359,7 @@ export const StepDetailsForm: FC = React.memo( destinationIndex, destinationIngestPipeline, valid, - indexPatternTimeField, + dataViewTimeField, /* eslint-enable react-hooks/exhaustive-deps */ ]); @@ -530,9 +528,7 @@ export const StepDetailsForm: FC = React.memo( ) : null} = React.memo( , ] : []), - ...(createIndexPattern && indexPatternTitleExists + ...(createDataView && dataViewTitleExists ? [ i18n.translate('xpack.transform.stepDetailsForm.dataViewTitleError', { defaultMessage: 'A data view with this title already exists.', @@ -553,25 +549,23 @@ export const StepDetailsForm: FC = React.memo( ]} > setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" + checked={createDataView === true} + onChange={() => setCreateDataView(!createDataView)} + data-test-subj="transformCreateDataViewSwitch" /> - {createIndexPattern && - !indexPatternTitleExists && - indexPatternAvailableTimeFields.length > 0 && ( - - )} + {createDataView && !dataViewTitleExists && dataViewAvailableTimeFields.length > 0 && ( + + )} {/* Continuous mode */} = React.memo((props) => { const { continuousModeDateField, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -28,14 +28,14 @@ export const StepDetailsSummary: FC = React.memo((props destinationIndex, destinationIngestPipeline, touched, - indexPatternTimeField, + dataViewTimeField, } = props; if (touched === false) { return null; } - const destinationIndexHelpText = createIndexPattern + const destinationIndexHelpText = createDataView ? i18n.translate('xpack.transform.stepDetailsSummary.createDataViewMessage', { defaultMessage: 'A Kibana data view will be created for this transform.', }) @@ -69,13 +69,13 @@ export const StepDetailsSummary: FC = React.memo((props > {destinationIndex} - {createIndexPattern && indexPatternTimeField !== undefined && indexPatternTimeField !== '' && ( + {createDataView && dataViewTimeField !== undefined && dataViewTimeField !== '' && ( - {indexPatternTimeField} + {dataViewTimeField} )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx index 8d7f6b451f985..d750bf6c7e1fd 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx @@ -11,14 +11,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; interface Props { - indexPatternAvailableTimeFields: string[]; - indexPatternTimeField: string | undefined; + dataViewAvailableTimeFields: string[]; + dataViewTimeField: string | undefined; onTimeFieldChanged: (e: React.ChangeEvent) => void; } export const StepDetailsTimeField: FC = ({ - indexPatternAvailableTimeFields, - indexPatternTimeField, + dataViewAvailableTimeFields, + dataViewTimeField, onTimeFieldChanged, }) => { const noTimeFieldLabel = i18n.translate( @@ -56,13 +56,13 @@ export const StepDetailsTimeField: FC = ({ > ({ text })), + ...dataViewAvailableTimeFields.map((text) => ({ text })), disabledDividerOption, noTimeFieldOption, ]} - value={indexPatternTimeField} + value={dataViewTimeField} onChange={onTimeFieldChanged} - data-test-subj="transformIndexPatternTimeFieldSelect" + data-test-subj="transformDataViewTimeFieldSelect" /> ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 27c43ed01a934..c16756d0923e9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -31,7 +31,7 @@ import { StepDetailsSummary, } from '../step_details'; import { WizardNav } from '../wizard_nav'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../src/plugins/data_views/public'; import type { RuntimeMappings } from '../step_define/common/types'; enum WIZARD_STEPS { @@ -86,26 +86,22 @@ interface WizardProps { } export const CreateTransformWizardContext = createContext<{ - indexPattern: IndexPattern | null; + dataView: DataView | null; runtimeMappings: RuntimeMappings | undefined; }>({ - indexPattern: null, + dataView: null, runtimeMappings: undefined, }); export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { - const { indexPattern } = searchItems; + const { dataView } = searchItems; // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState( - getDefaultStepDefineState(searchItems), - cloneConfig, - indexPattern - ) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig, dataView) ); // The DETAILS state @@ -117,7 +113,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) const [stepCreateState, setStepCreateState] = useState(getDefaultStepCreateState); const transformConfig = getCreateTransformRequestBody( - indexPattern.title, + dataView.title, stepDefineState, stepDetailsState ); @@ -180,12 +176,12 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) {currentStep === WIZARD_STEPS.CREATE ? ( ) : ( @@ -200,19 +196,19 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) }, [ currentStep, setCurrentStep, - stepDetailsState.createIndexPattern, + stepDetailsState.createDataView, stepDetailsState.transformId, transformConfig, setStepCreateState, stepCreateState, - stepDetailsState.indexPatternTimeField, + stepDetailsState.dataViewTimeField, ]); const stepsConfig = [stepDefine, stepDetails, stepCreate]; return ( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index cf2ec765dc06b..f6c700aef67cc 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -22,23 +22,23 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => const history = useHistory(); const appDeps = useAppDependencies(); const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const toastNotifications = useToastNotifications(); - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const clickHandler = useCallback( async (item: TransformListRow) => { try { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const indexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const dataViewId = getDataViewIdByTitle(dataViewTitle); - if (indexPatternId === undefined) { + if (dataViewId === undefined) { toastNotifications.addDanger( i18n.translate('xpack.transform.clone.noDataViewErrorPromptText', { defaultMessage: @@ -47,9 +47,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => }) ); } else { - history.push( - `/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?indexPatternId=${indexPatternId}` - ); + history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?dataViewId=${dataViewId}`); } } catch (e) { toastNotifications.addError(e, { @@ -62,10 +60,10 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => [ history, savedObjectsClient, - indexPatterns, + dataViews, toastNotifications, - loadIndexPatterns, - getIndexPatternIdByTitle, + loadDataViews, + getDataViewIdByTitle, ] ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index d5436d51c218b..e369d9e992e30 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -23,12 +23,12 @@ export const DeleteActionModal: FC = ({ closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, items, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }) => { @@ -81,15 +81,15 @@ export const DeleteActionModal: FC = ({ { } @@ -130,11 +130,11 @@ export const DeleteActionModal: FC = ({ /> )} - {userCanDeleteIndex && indexPatternExists && ( + {userCanDeleteIndex && dataViewExists && ( = ({ values: { destinationIndex: items[0] && items[0].config.dest.index }, } )} - checked={deleteIndexPattern} - onChange={toggleDeleteIndexPattern} + checked={deleteDataView} + onChange={toggleDeleteDataView} disabled={userCanDeleteDataView === false} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index b41dfe1c06a8a..357809b54746b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -40,18 +40,18 @@ export const useDeleteAction = (forceDisable: boolean) => { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, } = useDeleteIndexAndTargetIndex(items); const deleteAndCloseModal = () => { setModalVisible(false); const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; - const shouldDeleteDestIndexPattern = - userCanDeleteIndex && userCanDeleteDataView && indexPatternExists && deleteIndexPattern; + const shouldDeleteDestDataView = + userCanDeleteIndex && userCanDeleteDataView && dataViewExists && deleteDataView; // if we are deleting multiple transforms, then force delete all if at least one item has failed // else, force delete only when the item user picks has failed const forceDelete = isBulkAction @@ -64,7 +64,7 @@ export const useDeleteAction = (forceDisable: boolean) => { state: i.stats.state, })), deleteDestIndex: shouldDeleteDestIndex, - deleteDestIndexPattern: shouldDeleteDestIndexPattern, + deleteDestDataView: shouldDeleteDestDataView, forceDelete, }); }; @@ -103,14 +103,14 @@ export const useDeleteAction = (forceDisable: boolean) => { closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, isModalVisible, items, openModal, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx index 9c8945264f000..0f73f6aac40d3 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -52,7 +52,7 @@ describe('Transform: Transform List Actions ', () => { // prepare render( - + ); @@ -72,7 +72,7 @@ describe('Transform: Transform List Actions ', () => { itemCopy.stats.checkpointing.last.checkpoint = 0; render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx index 0a5342b3b0c25..f7cc72c2236b0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -23,7 +23,7 @@ export const discoverActionNameText = i18n.translate( export const isDiscoverActionDisabled = ( items: TransformListRow[], forceDisable: boolean, - indexPatternExists: boolean + dataViewExists: boolean ) => { if (items.length !== 1) { return true; @@ -38,14 +38,14 @@ export const isDiscoverActionDisabled = ( const transformNeverStarted = stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; - return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; + return forceDisable === true || dataViewExists === false || transformNeverStarted === true; }; export interface DiscoverActionNameProps { - indexPatternExists: boolean; + dataViewExists: boolean; items: TransformListRow[]; } -export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { +export const DiscoverActionName: FC = ({ dataViewExists, items }) => { const isBulkAction = items.length > 1; const item = items[0]; @@ -65,7 +65,7 @@ export const DiscoverActionName: FC = ({ indexPatternEx defaultMessage: 'Links to Discover are not supported as a bulk action.', } ); - } else if (!indexPatternExists) { + } else if (!dataViewExists) { disabledTransformMessage = i18n.translate( 'xpack.transform.transformList.discoverTransformNoDataViewToolTip', { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx index 9b1d7ed066404..71a45b572f833 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -20,7 +20,7 @@ import { DiscoverActionName, } from './discover_action_name'; -const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => +const getDataViewTitleFromTargetIndex = (item: TransformListRow) => Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; export type DiscoverAction = ReturnType; @@ -28,60 +28,59 @@ export const useDiscoverAction = (forceDisable: boolean) => { const appDeps = useAppDependencies(); const { share } = appDeps; const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); - const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + const [dataViewsLoaded, setDataViewsLoaded] = useState(false); useEffect(() => { - async function checkIndexPatternAvailability() { - await loadIndexPatterns(savedObjectsClient, indexPatterns); - setIndexPatternsLoaded(true); + async function checkDataViewAvailability() { + await loadDataViews(savedObjectsClient, dataViews); + setDataViewsLoaded(true); } - checkIndexPatternAvailability(); - }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + checkDataViewAvailability(); + }, [dataViews, loadDataViews, savedObjectsClient]); const clickHandler = useCallback( (item: TransformListRow) => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); if (!locator) return; - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); locator.navigateSync({ - indexPatternId, + indexPatternId: dataViewId, }); }, - [getIndexPatternIdByTitle, share] + [getDataViewIdByTitle, share] ); - const indexPatternExists = useCallback( + const dataViewExists = useCallback( (item: TransformListRow) => { - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); - return indexPatternId !== undefined; + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); + return dataViewId !== undefined; }, - [getIndexPatternIdByTitle] + [getDataViewIdByTitle] ); const action: TransformListAction = useMemo( () => ({ name: (item: TransformListRow) => { - return ; + return ; }, available: () => isDiscoverAvailable, enabled: (item: TransformListRow) => - indexPatternsLoaded && - !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + dataViewsLoaded && !isDiscoverActionDisabled([item], forceDisable, dataViewExists(item)), description: discoverActionNameText, icon: 'visTable', type: 'icon', onClick: clickHandler, 'data-test-subj': 'transformActionDiscover', }), - [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + [forceDisable, dataViewExists, dataViewsLoaded, isDiscoverAvailable, clickHandler] ); return { action }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index f789327a051e2..e4927fff97070 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -22,14 +22,14 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const [config, setConfig] = useState(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(); + const [dataViewId, setDataViewId] = useState(); const closeFlyout = () => setIsFlyoutVisible(false); - const { getIndexPatternIdByTitle } = useSearchItems(undefined); + const { getDataViewIdByTitle } = useSearchItems(undefined); const toastNotifications = useToastNotifications(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const clickHandler = useCallback( async (item: TransformListRow) => { @@ -37,9 +37,9 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const currentIndexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const currentDataViewId = getDataViewIdByTitle(dataViewTitle); - if (currentIndexPatternId === undefined) { + if (currentDataViewId === undefined) { toastNotifications.addWarning( i18n.translate('xpack.transform.edit.noDataViewErrorPromptText', { defaultMessage: @@ -48,7 +48,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => }) ); } - setIndexPatternId(currentIndexPatternId); + setDataViewId(currentDataViewId); setConfig(item.config); setIsFlyoutVisible(true); } catch (e) { @@ -60,7 +60,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [indexPatterns, toastNotifications, getIndexPatternIdByTitle] + [dataViews, toastNotifications, getDataViewIdByTitle] ); const action: TransformListAction = useMemo( @@ -81,6 +81,6 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => config, closeFlyout, isFlyoutVisible, - indexPatternId, + dataViewId, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index b988b61c5b0b7..e6648c5214dac 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -44,13 +44,13 @@ import { isManagedTransform } from '../../../../common/managed_transforms_utils' interface EditTransformFlyoutProps { closeFlyout: () => void; config: TransformConfigUnion; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyout: FC = ({ closeFlyout, config, - indexPatternId, + dataViewId, }) => { const api = useApi(); const toastNotifications = useToastNotifications(); @@ -110,10 +110,7 @@ export const EditTransformFlyout: FC = ({ /> ) : null} }> - + {errorMessage !== undefined && ( <> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index 22f31fc6139e8..fd0ca655f3056 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -29,12 +29,12 @@ import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/com interface EditTransformFlyoutFormProps { editTransformFlyout: UseEditTransformFlyoutReturnType; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyoutForm: FC = ({ editTransformFlyout: [state, dispatch], - indexPatternId, + dataViewId, }) => { const { formFields, formSections } = state; const [dateFieldNames, setDateFieldNames] = useState([]); @@ -43,16 +43,16 @@ export const EditTransformFlyoutForm: FC = ({ const isRetentionPolicyAvailable = dateFieldNames.length > 0; const appDeps = useAppDependencies(); - const indexPatternsClient = appDeps.data.indexPatterns; + const dataViewsClient = appDeps.data.dataViews; const api = useApi(); useEffect( function getDateFields() { let unmounted = false; - if (indexPatternId !== undefined) { - indexPatternsClient.get(indexPatternId).then((indexPattern) => { - if (indexPattern) { - const dateTimeFields = indexPattern.fields + if (dataViewId !== undefined) { + dataViewsClient.get(dataViewId).then((dataView) => { + if (dataView) { + const dateTimeFields = dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -66,7 +66,7 @@ export const EditTransformFlyoutForm: FC = ({ }; } }, - [indexPatternId, indexPatternsClient] + [dataViewId, dataViewsClient] ); useEffect(function fetchPipelinesOnMount() { @@ -153,7 +153,7 @@ export const EditTransformFlyoutForm: FC = ({ { // If data view or date fields info not available // gracefully defaults to text input - indexPatternId ? ( + dataViewId ? ( = ({ transf const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]); - const indexPatternTitle = Array.isArray(transformConfig.source.index) + const dataViewTitle = Array.isArray(transformConfig.source.index) ? transformConfig.source.index.join(',') : transformConfig.source.index; const pivotPreviewProps = usePivotData( - indexPatternTitle, + dataViewTitle, pivotQuery, validationStatus, previewRequest, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 5d480003c7600..986adb89bd41e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -52,7 +52,7 @@ export const useActions = ({ )} {deleteAction.isModalVisible && } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 066a72c807956..a5c536990353a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -192,7 +192,7 @@ export const TransformManagement: FC = () => { state: TRANSFORM_STATE.FAILED, })), deleteDestIndex: false, - deleteDestIndexPattern: false, + deleteDestDataView: false, forceDelete: true, } ); diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts index c8d3f625a9281..88b54a7487f92 100644 --- a/x-pack/plugins/transform/public/app/services/es_index_service.ts +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -7,7 +7,7 @@ import { HttpSetup, SavedObjectsClientContract } from 'kibana/public'; import { API_BASE_PATH } from '../../../common/constants'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/public'; export class IndexService { async canDeleteIndex(http: HttpSetup) { @@ -18,8 +18,8 @@ export class IndexService { return privilege.hasAllPrivileges; } - async indexPatternExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { - const response = await savedObjectsClient.find({ + async dataViewExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index bfe2f47078569..5f464949a4fc8 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { - indexPatternTitleSchema, - IndexPatternTitleSchema, -} from '../../../common/api_schemas/common'; +import { dataViewTitleSchema, DataViewTitleSchema } from '../../../common/api_schemas/common'; import { fieldHistogramsRequestSchema, FieldHistogramsRequestSchema, @@ -21,23 +18,23 @@ import { addBasePath } from '../index'; import { wrapError, wrapEsError } from './error_utils'; export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { - router.post( + router.post( { - path: addBasePath('field_histograms/{indexPatternTitle}'), + path: addBasePath('field_histograms/{dataViewTitle}'), validate: { - params: indexPatternTitleSchema, + params: dataViewTitleSchema, body: fieldHistogramsRequestSchema, }, }, - license.guardApiRoute( + license.guardApiRoute( async (ctx, req, res) => { - const { indexPatternTitle } = req.params; + const { dataViewTitle } = req.params; const { query, fields, runtimeMappings, samplerShardSize } = req.body; try { const resp = await getHistogramsForFields( ctx.core.elasticsearch.client, - indexPatternTitle, + dataViewTitle, query, fields, samplerShardSize, diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 2f82b9a70389b..78b51fca58547 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -61,7 +61,7 @@ import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError, wrapEsError } from './error_utils'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; import { registerTransformNodesRoutes } from './transforms_nodes'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; @@ -449,11 +449,8 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { registerTransformNodesRoutes(routeDependencies); } -async function getIndexPatternId( - indexName: string, - savedObjectsClient: SavedObjectsClientContract -) { - const response = await savedObjectsClient.find({ +async function getDataViewId(indexName: string, savedObjectsClient: SavedObjectsClientContract) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, @@ -464,11 +461,11 @@ async function getIndexPatternId( return ip?.id; } -async function deleteDestIndexPatternById( - indexPatternId: string, +async function deleteDestDataViewById( + dataViewId: string, savedObjectsClient: SavedObjectsClientContract ) { - return await savedObjectsClient.delete('index-pattern', indexPatternId); + return await savedObjectsClient.delete('index-pattern', dataViewId); } async function deleteTransforms( @@ -480,7 +477,7 @@ async function deleteTransforms( // Cast possible undefineds as booleans const deleteDestIndex = !!reqBody.deleteDestIndex; - const deleteDestIndexPattern = !!reqBody.deleteDestIndexPattern; + const deleteDestDataView = !!reqBody.deleteDestDataView; const shouldForceDelete = !!reqBody.forceDelete; const results: DeleteTransformsResponseSchema = {}; @@ -490,7 +487,7 @@ async function deleteTransforms( const transformDeleted: ResponseStatus = { success: false }; const destIndexDeleted: ResponseStatus = { success: false }; - const destIndexPatternDeleted: ResponseStatus = { + const destDataViewDeleted: ResponseStatus = { success: false, }; const transformId = transformInfo.id; @@ -516,7 +513,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; // No need to perform further delete attempts @@ -538,18 +535,15 @@ async function deleteTransforms( } // Delete the data view if there's a data view that matches the name of dest index - if (destinationIndex && deleteDestIndexPattern) { + if (destinationIndex && deleteDestDataView) { try { - const indexPatternId = await getIndexPatternId( - destinationIndex, - ctx.core.savedObjects.client - ); - if (indexPatternId) { - await deleteDestIndexPatternById(indexPatternId, ctx.core.savedObjects.client); - destIndexPatternDeleted.success = true; + const dataViewId = await getDataViewId(destinationIndex, ctx.core.savedObjects.client); + if (dataViewId) { + await deleteDestDataViewById(dataViewId, ctx.core.savedObjects.client); + destDataViewDeleted.success = true; } - } catch (deleteDestIndexPatternError) { - destIndexPatternDeleted.error = deleteDestIndexPatternError.meta.body.error; + } catch (deleteDestDataViewError) { + destDataViewDeleted.error = deleteDestDataViewError.meta.body.error; } } @@ -569,7 +563,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; } catch (e) { diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts index b823c46509a63..7f14081e5c574 100644 --- a/x-pack/test/api_integration/apis/transform/delete_transforms.ts +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); }); @@ -148,7 +148,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -178,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -219,13 +219,13 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); }); }); - describe('with deleteDestIndexPattern setting', function () { + describe('with deleteDestDataView setting', function () { const transformId = 'test3'; const destinationIndex = generateDestIndex(transformId); @@ -244,7 +244,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: false, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -258,14 +258,14 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); }); }); - describe('with deleteDestIndex & deleteDestIndexPattern setting', function () { + describe('with deleteDestIndex & deleteDestDataView setting', function () { const transformId = 'test4'; const destinationIndex = generateDestIndex(transformId); @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: true, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -298,7 +298,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index 382f1b5ba75ab..3cbb0892bd4ec 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -333,8 +333,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('should display the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('should display the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 37647b48d3180..dc8190c877d61 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -589,8 +589,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts index 72467b3060ab1..2c7889572ce74 100644 --- a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts +++ b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts @@ -401,8 +401,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index acdc0c64ddda2..b33027da24341 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -232,8 +232,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 714ae52a6641b..2b95570a9fb1a 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -670,13 +670,13 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi await this.assertDestinationIndexValue(destinationIndex); }, - async assertCreateIndexPatternSwitchExists() { - await testSubjects.existOrFail(`transformCreateIndexPatternSwitch`, { allowHidden: true }); + async assertCreateDataViewSwitchExists() { + await testSubjects.existOrFail(`transformCreateDataViewSwitch`, { allowHidden: true }); }, - async assertCreateIndexPatternSwitchCheckState(expectedCheckState: boolean) { + async assertCreateDataViewSwitchCheckState(expectedCheckState: boolean) { const actualCheckState = - (await testSubjects.getAttribute('transformCreateIndexPatternSwitch', 'aria-checked')) === + (await testSubjects.getAttribute('transformCreateDataViewSwitch', 'aria-checked')) === 'true'; expect(actualCheckState).to.eql( expectedCheckState, From f04433e1a3c9cb8508a8911e4f082846da8de36a Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 24 Mar 2022 13:39:41 +0100 Subject: [PATCH 50/66] [Uptime] Enable uptime inspect on dev (#128469) --- x-pack/plugins/uptime/public/apps/plugin.ts | 9 ++++++++- x-pack/plugins/uptime/public/apps/render_app.tsx | 4 +++- x-pack/plugins/uptime/public/apps/uptime_app.tsx | 1 + .../components/common/header/inspector_header_link.tsx | 5 ++++- .../uptime/public/contexts/uptime_settings_context.tsx | 5 +++++ .../server/lib/adapters/framework/adapter_types.ts | 1 + x-pack/plugins/uptime/server/lib/lib.ts | 6 ++++-- x-pack/plugins/uptime/server/plugin.ts | 1 + .../uptime/server/rest_api/uptime_route_wrapper.ts | 5 +++-- 9 files changed, 30 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index bf7c5336a8b0f..278ce45cdf593 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -215,7 +215,14 @@ export class UptimePlugin const [coreStart, corePlugins] = await core.getStartServices(); const { renderApp } = await import('./render_app'); - return renderApp(coreStart, plugins, corePlugins, params, config); + return renderApp( + coreStart, + plugins, + corePlugins, + params, + config, + this.initContext.env.mode.dev + ); }, }); } diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index 23f8fc9a8e58c..44e9651c25dd1 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -25,7 +25,8 @@ export function renderApp( plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, appMountParameters: AppMountParameters, - config: UptimeUiConfig + config: UptimeUiConfig, + isDev: boolean ) { const { application: { capabilities }, @@ -45,6 +46,7 @@ export function renderApp( plugins.share.url.locators.create(uptimeOverviewNavigatorParams); const props: UptimeAppProps = { + isDev, plugins, canSave, core, diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 5df0d1a00f905..12519143d347a 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -65,6 +65,7 @@ export interface UptimeAppProps { setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; appMountParameters: AppMountParameters; config: UptimeUiConfig; + isDev: boolean; } const Application = (props: UptimeAppProps) => { diff --git a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx index 8f787512aaf9d..c55ef9bdf781e 100644 --- a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx @@ -11,12 +11,15 @@ import React from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { enableInspectEsQueries, useInspectorContext } from '../../../../../observability/public'; import { ClientPluginsStart } from '../../../apps/plugin'; +import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; export function InspectorHeaderLink() { const { services: { inspector, uiSettings }, } = useKibana(); + const { isDev } = useUptimeSettingsContext(); + const { inspectorAdapters } = useInspectorContext(); const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); @@ -25,7 +28,7 @@ export function InspectorHeaderLink() { inspector.open(inspectorAdapters); }; - if (!isInspectorEnabled) { + if (!isInspectorEnabled && !isDev) { return null; } 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 63f21a23e30d3..67058be9a9d65 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -21,6 +21,7 @@ export interface UptimeSettingsContextValues { isLogsAvailable: boolean; config: UptimeUiConfig; commonlyUsedRanges?: CommonlyUsedRange[]; + isDev?: boolean; } const { BASE_PATH } = CONTEXT_DEFAULTS; @@ -39,6 +40,7 @@ const defaultContext: UptimeSettingsContextValues = { isInfraAvailable: true, isLogsAvailable: true, config: {}, + isDev: false, }; export const UptimeSettingsContext = createContext(defaultContext); @@ -50,12 +52,14 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr isLogsAvailable, commonlyUsedRanges, config, + isDev, } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); const value = useMemo(() => { return { + isDev, basePath, isApmAvailable, isInfraAvailable, @@ -66,6 +70,7 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ + isDev, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index d9dadc81397ce..fb5c0cd1e69a1 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -62,6 +62,7 @@ export interface UptimeServerSetup { telemetry: TelemetryEventsSender; uptimeEsClient: UptimeESClient; basePath: IBasePath; + isDev?: boolean; } export interface UptimeCorePluginsSetup { diff --git a/x-pack/plugins/uptime/server/lib/lib.ts b/x-pack/plugins/uptime/server/lib/lib.ts index f61497816e2d9..220ac5a3797a4 100644 --- a/x-pack/plugins/uptime/server/lib/lib.ts +++ b/x-pack/plugins/uptime/server/lib/lib.ts @@ -49,7 +49,9 @@ export function createUptimeESClient({ esClient, request, savedObjectsClient, + isInspectorEnabled, }: { + isInspectorEnabled?: boolean; esClient: ElasticsearchClient; request?: KibanaRequest; savedObjectsClient: SavedObjectsClientContract; @@ -94,7 +96,7 @@ export function createUptimeESClient({ startTime: startTimeNow, }) ); - if (request) { + if (request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'search', params: esParams }); } } @@ -123,7 +125,7 @@ export function createUptimeESClient({ } const inspectableEsQueries = inspectableEsQueriesMap.get(request!); - if (inspectableEsQueries && request) { + if (inspectableEsQueries && request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'count', params: esParams }); } diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 2f329aa83a5c4..61272651e1ce2 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -81,6 +81,7 @@ export class Plugin implements PluginType { basePath: core.http.basePath, logger: this.logger, telemetry: this.telemetryEventsSender, + isDev: this.initContext.env.mode.dev, } as UptimeServerSetup; if (this.isServiceEnabled && this.server.config.service) { diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index cf03e7d58fd14..60ba60087382a 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -41,12 +41,13 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => const uptimeEsClient = createUptimeESClient({ request, savedObjectsClient, + isInspectorEnabled, esClient: esClient.asCurrentUser, }); server.uptimeEsClient = uptimeEsClient; - if (isInspectorEnabled) { + if (isInspectorEnabled || server.isDev) { inspectableEsQueriesMap.set(request, []); } @@ -66,7 +67,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => return response.ok({ body: { ...res, - ...(isInspectorEnabled && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS + ...((isInspectorEnabled || server.isDev) && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS ? { _inspect: inspectableEsQueriesMap.get(request) } : {}), }, From 4e4a26a4cf550fee9c1820d4b90301390bb6d1ee Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Mar 2022 13:40:33 +0100 Subject: [PATCH 51/66] [Lens] Make sure x axis values are always strings (#128160) * make sure x axis values are always strings * Update expression.tsx Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/lens/public/xy_visualization/expression.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 8b62b8d0c120c..105b9d24bb09b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -294,8 +294,8 @@ export function XYChart({ // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] - ? (value as string) - : xAxisFormatter.convert(value); + ? String(value) + : String(xAxisFormatter.convert(value)); const chartHasMoreThanOneSeries = filteredLayers.length > 1 || From 99a044aba89b5a3bebc97380c724d297ffb239d3 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 24 Mar 2022 08:51:22 -0400 Subject: [PATCH 52/66] [Security Solution][Endpoint] Add generic Console component in support of Response Actions (#127885) * Generic Console component * "hidden" dev console instance on endpoint list (behind URL param) --- .../console/components/bad_argument.tsx | 39 +++ .../components/command_execution_failure.tsx | 17 ++ .../components/command_execution_output.tsx | 107 +++++++ .../command_input/command_input.test.tsx | 43 +++ .../command_input/command_input.tsx | 143 ++++++++++ .../console/components/command_input/index.ts | 9 + .../components/command_input/key_capture.tsx | 156 ++++++++++ .../console/components/command_list.tsx | 54 ++++ .../console/components/command_usage.tsx | 114 ++++++++ .../console_magenement_provider/index.ts | 8 + .../console_state/console_state.tsx | 47 +++ .../console/components/console_state/index.ts | 8 + .../components/console_state/state_reducer.ts | 42 +++ .../handle_execute_command.test.tsx | 192 +++++++++++++ .../handle_execute_command.tsx | 269 ++++++++++++++++++ .../console/components/console_state/types.ts | 39 +++ .../console/components/help_output.tsx | 59 ++++ .../console/components/history_item.tsx | 31 ++ .../console/components/history_output.tsx | 42 +++ .../console/components/unknow_comand.tsx | 46 +++ .../console/components/user_command_input.tsx | 22 ++ .../components/console/console.test.tsx | 41 +++ .../management/components/console/console.tsx | 100 +++++++ .../use_builtin_command_service.ts | 13 + .../state_selectors/use_command_history.ts | 12 + .../state_selectors/use_command_service.ts | 13 + .../use_console_state_dispatch.ts | 13 + .../state_selectors/use_data_test_subj.ts | 12 + .../management/components/console/index.ts | 10 + .../management/components/console/mocks.tsx | 176 ++++++++++++ .../service/builtin_command_service.tsx | 102 +++++++ .../console/service/parsed_command_input.ts | 95 +++++++ .../service/types.builtin_command_service.ts | 27 ++ .../service/usage_from_command_definition.ts | 33 +++ .../management/components/console/types.ts | 64 +++++ .../endpoint_console/endpoint_console.tsx | 25 ++ .../endpoint_console_command_service.tsx | 22 ++ .../components/endpoint_console/index.ts | 8 + .../pages/endpoint_hosts/view/dev_console.tsx | 95 +++++++ .../pages/endpoint_hosts/view/index.tsx | 4 + 40 files changed, 2352 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/console.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/console.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/mocks.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx new file mode 100644 index 0000000000000..8ff4b71668fd4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, PropsWithChildren } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { ParsedCommandInput } from '../service/parsed_command_input'; +import { CommandDefinition } from '../types'; +import { CommandInputUsage } from './command_usage'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export type BadArgumentProps = PropsWithChildren<{ + parsedInput: ParsedCommandInput; + commandDefinition: CommandDefinition; +}>; + +export const BadArgument = memo( + ({ parsedInput, commandDefinition, children = null }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> + + + + + {children} + + + + ); + } +); +BadArgument.displayName = 'BadArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx new file mode 100644 index 0000000000000..2205bb38d0aea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +export interface CommandExecutionFailureProps { + error: Error; +} +export const CommandExecutionFailure = memo(({ error }) => { + return {error}; +}); +CommandExecutionFailure.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx new file mode 100644 index 0000000000000..8bb9769980914 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiLoadingChart } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { UserCommandInput } from './user_command_input'; +import { Command } from '../types'; +import { useCommandService } from '../hooks/state_selectors/use_command_service'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; + +const CommandOutputContainer = styled.div` + position: relative; + + .run-in-background { + position: absolute; + right: 0; + top: 1em; + } +`; + +export interface CommandExecutionOutputProps { + command: Command; +} +export const CommandExecutionOutput = memo(({ command }) => { + const commandService = useCommandService(); + const [isRunning, setIsRunning] = useState(true); + const [output, setOutput] = useState(null); + const dispatch = useConsoleStateDispatch(); + + // FIXME:PT implement the `run in the background` functionality + const [showRunInBackground, setShowRunInTheBackground] = useState(false); + const handleRunInBackgroundClick = useCallback(() => { + setShowRunInTheBackground(false); + }, []); + + useEffect(() => { + (async () => { + const timeoutId = setTimeout(() => { + setShowRunInTheBackground(true); + }, 15000); + + try { + const commandOutput = await commandService.executeCommand(command); + setOutput(commandOutput.result); + + // FIXME: PT the console should scroll the bottom as well + } catch (error) { + setOutput(); + } + + clearTimeout(timeoutId); + setIsRunning(false); + setShowRunInTheBackground(false); + })(); + }, [command, commandService]); + + useEffect(() => { + if (!isRunning) { + dispatch({ type: 'scrollDown' }); + } + }, [isRunning, dispatch]); + + return ( + + {showRunInBackground && ( +
    + + + +
    + )} +
    + + {isRunning && ( + <> + + + )} +
    +
    {output}
    +
    + ); +}); +CommandExecutionOutput.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx new file mode 100644 index 0000000000000..e61318227cb1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConsoleProps } from '../../console'; +import { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { ConsoleTestSetup, getConsoleTestSetup } from '../../mocks'; + +describe('When entering data into the Console input', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display what the user is typing', () => { + render(); + + enterCommand('c', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + + enterCommand('m', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + }); + + it('should delete last character when BACKSPACE is pressed', () => { + render(); + + enterCommand('cm', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + + enterCommand('{backspace}', { inputOnly: true, useKeyboard: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx new file mode 100644 index 0000000000000..f9b12391e6f6b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import classNames from 'classnames'; +import { KeyCapture, KeyCaptureProps } from './key_capture'; +import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const CommandInputContainer = styled.div` + .prompt { + padding-right: 1ch; + } + + .textEntered { + white-space: break-spaces; + } + + .cursor { + display: inline-block; + width: 0.5em; + height: 1em; + background-color: ${({ theme }) => theme.eui.euiTextColors.default}; + + animation: cursor-blink-animation 1s steps(5, start) infinite; + -webkit-animation: cursor-blink-animation 1s steps(5, start) infinite; + @keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + @-webkit-keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + + &.inactive { + background-color: transparent !important; + } + } +`; + +export interface CommandInputProps extends CommonProps { + prompt?: string; + isWaiting?: boolean; + focusRef?: KeyCaptureProps['focusRef']; +} + +export const CommandInput = memo( + ({ prompt = '>', focusRef, ...commonProps }) => { + const dispatch = useConsoleStateDispatch(); + const [textEntered, setTextEntered] = useState(''); + const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false); + const _focusRef: KeyCaptureProps['focusRef'] = useRef(null); + const textDisplayRef = useRef(null); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const keyCaptureFocusRef = focusRef || _focusRef; + + const handleKeyCaptureOnStateChange = useCallback< + NonNullable + >((isCapturing) => { + setIsKeyInputBeingCaptured(isCapturing); + }, []); + + const handleTypingAreaClick = useCallback( + (ev) => { + if (keyCaptureFocusRef.current) { + keyCaptureFocusRef.current(); + } + }, + [keyCaptureFocusRef] + ); + + const handleKeyCapture = useCallback( + ({ value, eventDetails }) => { + setTextEntered((prevState) => { + let updatedState = prevState + value; + + switch (eventDetails.keyCode) { + // BACKSPACE + // remove the last character from the text entered + case 8: + if (updatedState.length) { + updatedState = updatedState.replace(/.$/, ''); + } + break; + + // ENTER + // Execute command and blank out the input area + case 13: + dispatch({ type: 'executeCommand', payload: { input: updatedState } }); + return ''; + } + + return updatedState; + }); + }, + [dispatch] + ); + + return ( + + + + {prompt} + + + {textEntered} + + + + + + + + ); + } +); +CommandInput.displayName = 'CommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts new file mode 100644 index 0000000000000..4db81ade86011 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CommandInput } from './command_input'; +export type { CommandInputProps } from './command_input'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx new file mode 100644 index 0000000000000..03bb133f88d79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { + FormEventHandler, + KeyboardEventHandler, + memo, + MutableRefObject, + useCallback, + useRef, + useState, +} from 'react'; +import { pick } from 'lodash'; +import styled from 'styled-components'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const NOOP = () => undefined; + +const KeyCaptureContainer = styled.span` + display: inline-block; + position: relative; + width: 1px; + height: 1em; + overflow: hidden; + + .invisible-input { + &, + &:focus { + border: none; + background-image: none; + background-color: transparent; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + animation: none !important; + width: 1ch !important; + position: absolute; + left: -100px; + top: -100px; + } + } +`; + +export interface KeyCaptureProps { + onCapture: (params: { + value: string | undefined; + eventDetails: Pick< + KeyboardEvent, + 'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey' + >; + }) => void; + onStateChange?: (isCapturing: boolean) => void; + focusRef?: MutableRefObject<((force?: boolean) => void) | null>; +} + +/** + * Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of + * the console. It's sole purpose is to capture what the user types, which is then pass along to be + * displayed in a more UX friendly way + */ +export const KeyCapture = memo(({ onCapture, focusRef, onStateChange }) => { + // We don't need the actual value that was last input in this component, because + // `setLastInput()` is used with a function that returns the typed character. + // This state is used like this: + // 1. user presses a keyboard key + // 2. `input` event is triggered - we store the letter typed + // 3. the next event to be triggered (after `input`) that we listen for is `keyup`, + // and when that is triggered, we take the input letter (already stored) and + // call `onCapture()` with it and then set the lastInput state back to an empty string + const [, setLastInput] = useState(''); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const handleBlurAndFocus = useCallback( + (ev) => { + if (!onStateChange) { + return; + } + + onStateChange(ev.type === 'focus'); + }, + [onStateChange] + ); + + const handleOnKeyUp = useCallback>( + (ev) => { + ev.stopPropagation(); + + const eventDetails = pick(ev, [ + 'key', + 'altKey', + 'ctrlKey', + 'keyCode', + 'metaKey', + 'repeat', + 'shiftKey', + ]); + + setLastInput((value) => { + onCapture({ + value, + eventDetails, + }); + + return ''; + }); + }, + [onCapture] + ); + + const handleOnInput = useCallback>((ev) => { + const newValue = ev.currentTarget.value; + + setLastInput((prevState) => { + return `${prevState || ''}${newValue}`; + }); + }, []); + + const inputRef = useRef(null); + + const setFocus = useCallback((force: boolean = false) => { + // If user selected text and `force` is not true, then don't focus (else they lose selection) + if (!force && (window.getSelection()?.toString() ?? '').length > 0) { + return; + } + + inputRef.current?.focus(); + }, []); + + if (focusRef) { + focusRef.current = setFocus; + } + + // FIXME:PT probably need to add `aria-` type properties to the input? + return ( + + + + ); +}); +KeyCapture.displayName = 'KeyCapture'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx new file mode 100644 index 0000000000000..d7464e2f97391 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface CommandListProps { + commands: CommandDefinition[]; +} + +export const CommandList = memo(({ commands }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const footerMessage = useMemo(() => { + return ( + {'some-command --help'}, + }} + /> + ); + }, []); + + return ( + <> + + {commands.map(({ name, about }) => { + return ( + + + + ); + })} + + {footerMessage} + + ); +}); +CommandList.displayName = 'CommandList'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx new file mode 100644 index 0000000000000..9d17d83f0266f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { usageFromCommandDefinition } from '../service/usage_from_command_definition'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export const CommandInputUsage = memo>(({ commandDef }) => { + const usageHelp = useMemo(() => { + return usageFromCommandDefinition(commandDef); + }, [commandDef]); + + return ( + + + + + + + + + + {usageHelp} + + + + + ); +}); +CommandInputUsage.displayName = 'CommandInputUsage'; + +export interface CommandUsageProps { + commandDef: CommandDefinition; +} + +export const CommandUsage = memo(({ commandDef }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]); + const commandOptions = useMemo(() => { + // `command.args` only here to silence TS check + if (!hasArgs || !commandDef.args) { + return []; + } + + return Object.entries(commandDef.args).map(([option, { about: description }]) => ({ + title: `--${option}`, + description, + })); + }, [commandDef.args, hasArgs]); + const additionalProps = useMemo( + () => ({ + className: 'euiTruncateText', + }), + [] + ); + + return ( + + {commandDef.about} + + {hasArgs && ( + <> + +

    + + + {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( + + + + )} + +

    + {commandDef.args && ( + + )} + + )} +
    + ); +}); +CommandUsage.displayName = 'CommandUsage'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts new file mode 100644 index 0000000000000..8d7de159bbc5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// FIXME:PT implement a React context to manage consoles diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx new file mode 100644 index 0000000000000..852b2b1ab58fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react'; +import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer'; +import { ConsoleStore } from './types'; + +const ConsoleStateContext = createContext(null); + +type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface; + +/** + * A Console wide data store for internal state management between inner components + */ +export const ConsoleStateProvider = memo( + ({ commandService, scrollToBottom, dataTestSubj, children }) => { + const [state, dispatch] = useReducer( + stateDataReducer, + { commandService, scrollToBottom, dataTestSubj }, + initiateState + ); + + // FIXME:PT should handle cases where props that are in the store change + // Probably need to have a `useAffect()` that just does a `dispatch()` to update those. + + return ( + + {children} + + ); + } +); +ConsoleStateProvider.displayName = 'ConsoleStateProvider'; + +export const useConsoleStore = (): ConsoleStore => { + const store = useContext(ConsoleStateContext); + + if (!store) { + throw new Error(`ConsoleStateContext not defined`); + } + + return store; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts new file mode 100644 index 0000000000000..dc59ac1c2acef --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ConsoleStateProvider } from './console_state'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts new file mode 100644 index 0000000000000..94175d9821ae7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConsoleDataState, ConsoleStoreReducer } from './types'; +import { handleExecuteCommand } from './state_update_handlers/handle_execute_command'; +import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service'; + +export type InitialStateInterface = Pick< + ConsoleDataState, + 'commandService' | 'scrollToBottom' | 'dataTestSubj' +>; + +export const initiateState = ({ + commandService, + scrollToBottom, + dataTestSubj, +}: InitialStateInterface): ConsoleDataState => { + return { + commandService, + scrollToBottom, + dataTestSubj, + commandHistory: [], + builtinCommandService: new ConsoleBuiltinCommandsService(), + }; +}; + +export const stateDataReducer: ConsoleStoreReducer = (state, action) => { + switch (action.type) { + case 'scrollDown': + state.scrollToBottom(); + return state; + + case 'executeCommand': + return handleExecuteCommand(state, action); + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx new file mode 100644 index 0000000000000..b6a8e4db52340 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ConsoleProps } from '../../../console'; +import { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../../../mocks'; +import type { ConsoleTestSetup } from '../../../mocks'; +import { waitFor } from '@testing-library/react'; + +describe('When a Console command is entered by the user', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let commandServiceMock: ConsoleTestSetup['commandServiceMock']; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ commandServiceMock, enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display all available commands when `help` command is entered', async () => { + render(); + enterCommand('help'); + + expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy(); + + await waitFor(() => { + expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength( + // `+2` to account for builtin commands + commandServiceMock.getCommandList().length + 2 + ); + }); + }); + + it('should display custom help output when Command service has `getHelp()` defined', async () => { + commandServiceMock.getHelp = async () => { + return { + result:
    {'help output'}
    , + }; + }; + render(); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + + it('should clear the command output history when `clear` is entered', async () => { + render(); + enterCommand('help'); + enterCommand('help'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(2); + + enterCommand('clear'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(0); + }); + + it('should show individual command help when `--help` option is used', async () => { + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('test-commandUsage')).toBeTruthy()); + }); + + it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => { + commandServiceMock.getCommandUsage = async () => { + return { + result:
    {'command help here'}
    , + }; + }; + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('cmd-help')).toBeTruthy()); + }); + + it('should execute a command entered', async () => { + render(); + enterCommand('cmd1'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should allow multiple of the same options if `allowMultiples` is `true`', async () => { + render(); + enterCommand('cmd3 --foo one --foo two'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should show error if unknown command', async () => { + render(); + enterCommand('foo-foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual( + 'Unknown commandFor a list of available command, enter: help' + ); + }); + }); + + it('should show error if options are used but command supports none', async () => { + render(); + enterCommand('cmd1 --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'command does not support any argumentsUsage:cmd1' + ); + }); + }); + + it('should show error if unknown option is used', async () => { + render(); + enterCommand('cmd2 --file test --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'unsupported argument: --fooUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if any required option is not set', async () => { + render(); + enterCommand('cmd2 --ext one'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required argument: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if argument is used more than one', async () => { + render(); + enterCommand('cmd2 --file one --file two'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'argument can only be used once: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it("should show error returned by the option's `validate()` callback", async () => { + render(); + enterCommand('cmd2 --file one --bad foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'invalid argument value: --bad. This is a bad valueUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error no options were provided, bug command requires some', async () => { + render(); + enterCommand('cmd2'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required arguments: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if all arguments are optional, but at least 1 must be defined', async () => { + render(); + enterCommand('cmd4'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'at least one argument must be usedUsage:cmd4 [--foo --bar]' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx new file mode 100644 index 0000000000000..2815ec4605917 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint complexity: ["error", 40]*/ +// FIXME:PT remove the complexity + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types'; +import { parseCommandInput } from '../../../service/parsed_command_input'; +import { HistoryItem } from '../../history_item'; +import { UnknownCommand } from '../../unknow_comand'; +import { HelpOutput } from '../../help_output'; +import { BadArgument } from '../../bad_argument'; +import { CommandExecutionOutput } from '../../command_execution_output'; +import { CommandDefinition } from '../../../types'; + +const toCliArgumentOption = (argName: string) => `--${argName}`; + +const getRequiredArguments = (argDefinitions: CommandDefinition['args']): string[] => { + if (!argDefinitions) { + return []; + } + + return Object.entries(argDefinitions) + .filter(([_, argDef]) => argDef.required) + .map(([argName]) => argName); +}; + +const updateStateWithNewCommandHistoryItem = ( + state: ConsoleDataState, + newHistoryItem: ConsoleDataState['commandHistory'][number] +): ConsoleDataState => { + return { + ...state, + commandHistory: [...state.commandHistory, newHistoryItem], + }; +}; + +export const handleExecuteCommand: ConsoleStoreReducer< + ConsoleDataAction & { type: 'executeCommand' } +> = (state, action) => { + const parsedInput = parseCommandInput(action.payload.input); + + if (parsedInput.name === '') { + return state; + } + + const { commandService, builtinCommandService } = state; + + // Is it an internal command? + if (builtinCommandService.isBuiltin(parsedInput.name)) { + const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService); + + if (commandOutput.clearBuffer) { + return { + ...state, + commandHistory: [], + }; + } + + return updateStateWithNewCommandHistoryItem(state, commandOutput.result); + } + + // ---------------------------------------------------- + // Validate and execute the user defined command + // ---------------------------------------------------- + const commandDefinition = commandService + .getCommandList() + .find((definition) => definition.name === parsedInput.name); + + // Unknown command + if (!commandDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + + ); + } + + const requiredArgs = getRequiredArguments(commandDefinition.args); + + // If args were entered, then validate them + if (parsedInput.hasArgs()) { + // Show command help + if (parsedInput.hasArg('help')) { + return updateStateWithNewCommandHistoryItem( + state, + + + {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)( + commandDefinition + )} + + + ); + } + + // Command supports no arguments + if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', + { + defaultMessage: 'command does not support any arguments', + } + )} + + + ); + } + + // no unknown arguments allowed? + if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', { + defaultMessage: 'unknown argument(s): {unknownArgs}', + values: { + unknownArgs: parsedInput.unknownArgs.join(', '), + }, + })} + + + ); + } + + // Missing required Arguments + for (const requiredArg of requiredArgs) { + if (!parsedInput.args[requiredArg]) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.missingRequiredArg', + { + defaultMessage: 'missing required argument: {argName}', + values: { + argName: toCliArgumentOption(requiredArg), + }, + } + )} + + + ); + } + } + + // Validate each argument given to the command + for (const argName of Object.keys(parsedInput.args)) { + const argDefinition = commandDefinition.args[argName]; + const argInput = parsedInput.args[argName]; + + // Unknown argument + if (!argDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', { + defaultMessage: 'unsupported argument: {argName}', + values: { argName: toCliArgumentOption(argName) }, + })} + + + ); + } + + // does not allow multiple values + if ( + !argDefinition.allowMultiples && + Array.isArray(argInput.values) && + argInput.values.length > 0 + ) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', + { + defaultMessage: 'argument can only be used once: {argName}', + values: { argName: toCliArgumentOption(argName) }, + } + )} + + + ); + } + + if (argDefinition.validate) { + const validationResult = argDefinition.validate(argInput); + + if (validationResult !== true) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.invalidArgValue', + { + defaultMessage: 'invalid argument value: {argName}. {error}', + values: { argName: toCliArgumentOption(argName), error: validationResult }, + } + )} + + + ); + } + } + } + } else if (requiredArgs.length > 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', { + defaultMessage: 'missing required arguments: {requiredArgs}', + values: { + requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '), + }, + })} + + + ); + } else if (commandDefinition.mustHaveArgs) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { + defaultMessage: 'at least one argument must be used', + })} + + + ); + } + + // All is good. Execute the command + return updateStateWithNewCommandHistoryItem( + state, + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts new file mode 100644 index 0000000000000..72810d31e3248 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Dispatch, Reducer } from 'react'; +import { CommandServiceInterface } from '../../types'; +import { HistoryItemComponent } from '../history_item'; +import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service'; + +export interface ConsoleDataState { + /** Command service defined on input to the `Console` component by consumers of the component */ + commandService: CommandServiceInterface; + /** Command service for builtin console commands */ + builtinCommandService: BuiltinCommandServiceInterface; + /** UI function that scrolls the console down to the bottom */ + scrollToBottom: () => void; + /** + * List of commands entered by the user and being shown in the UI + */ + commandHistory: Array>; + dataTestSubj?: string; +} + +export type ConsoleDataAction = + | { type: 'scrollDown' } + | { type: 'executeCommand'; payload: { input: string } }; + +export interface ConsoleStore { + state: ConsoleDataState; + dispatch: Dispatch; +} + +export type ConsoleStoreReducer = Reducer< + ConsoleDataState, + A +>; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx new file mode 100644 index 0000000000000..b0a2217e169c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useEffect, useState } from 'react'; +import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface HelpOutputProps extends Pick { + input: string; + children: ReactNode | Promise<{ result: ReactNode }>; +} +export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => { + const [content, setContent] = useState(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + useEffect(() => { + if (children instanceof Promise) { + (async () => { + try { + const response = await (children as Promise<{ + result: ReactNode; + }>); + setContent(response.result); + } catch (error) { + setContent(); + } + })(); + + return; + } + + setContent(children); + }, [children]); + + return ( +
    +
    + +
    + + {content} + +
    + ); +}); +HelpOutput.displayName = 'HelpOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx new file mode 100644 index 0000000000000..0143d36f0e766 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, PropsWithChildren } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type HistoryItemProps = PropsWithChildren<{}>; + +export const HistoryItem = memo(({ children }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + + {children} + + ); +}); + +HistoryItem.displayName = 'HistoryItem'; + +export type HistoryItemComponent = typeof HistoryItem; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx new file mode 100644 index 0000000000000..088a6fac57ae4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useEffect } from 'react'; +import { CommonProps, EuiFlexGroup } from '@elastic/eui'; +import { useCommandHistory } from '../hooks/state_selectors/use_command_history'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type OutputHistoryProps = CommonProps; + +export const HistoryOutput = memo((commonProps) => { + const historyItems = useCommandHistory(); + const dispatch = useConsoleStateDispatch(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + // Anytime we add a new item to the history + // scroll down so that command input remains visible + useEffect(() => { + dispatch({ type: 'scrollDown' }); + }, [dispatch, historyItems.length]); + + return ( + + {historyItems} + + ); +}); + +HistoryOutput.displayName = 'HistoryOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx new file mode 100644 index 0000000000000..5529457cbb05a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { UserCommandInput } from './user_command_input'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export interface UnknownCommand { + input: string; +} +export const UnknownCommand = memo(({ input }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> +
    + +
    + + + + + + {'help'}, + }} + /> + + + + ); +}); +UnknownCommand.displayName = 'UnknownCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx new file mode 100644 index 0000000000000..84afff3f28209 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +export interface UserCommandInputProps { + input: string; +} + +export const UserCommandInput = memo(({ input }) => { + return ( + <> + {'$ '} + {input} + + ); +}); +UserCommandInput.displayName = 'UserCommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx new file mode 100644 index 0000000000000..9adeaa72d683e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppContextTestRender } from '../../../common/mock/endpoint'; +import { ConsoleProps } from './console'; +import { getConsoleTestSetup } from './mocks'; +import userEvent from '@testing-library/user-event'; + +describe('When using Console component', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should render console', () => { + render(); + + expect(renderResult.getByTestId('test')).toBeTruthy(); + }); + + it('should display prompt given on input', () => { + render({ prompt: 'MY PROMPT>>' }); + + expect(renderResult.getByTestId('test-cmdInput-prompt').textContent).toEqual('MY PROMPT>>'); + }); + + it('should focus on input area when it gains focus', () => { + render(); + userEvent.click(renderResult.getByTestId('test-mainPanel')); + + expect(document.activeElement!.classList.contains('invisible-input')).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx new file mode 100644 index 0000000000000..6c64a045c86fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useRef } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { HistoryOutput } from './components/history_output'; +import { CommandInput, CommandInputProps } from './components/command_input'; +import { CommandServiceInterface } from './types'; +import { ConsoleStateProvider } from './components/console_state'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; + +// FIXME:PT implement dark mode for the console or light mode switch + +const ConsoleWindow = styled.div` + height: 100%; + + // FIXME: IMPORTANT - this should NOT be used in production + // dark mode on light theme / light mode on dark theme + filter: invert(100%); + + .ui-panel { + min-width: ${({ theme }) => theme.eui.euiBreakpoints.s}; + height: 100%; + min-height: 300px; + overflow-y: auto; + } + + .descriptionList-20_80 { + &.euiDescriptionList { + > .euiDescriptionList__title { + width: 20%; + } + + > .euiDescriptionList__description { + width: 80%; + } + } + } +`; + +export interface ConsoleProps extends CommonProps, Pick { + commandService: CommandServiceInterface; +} + +export const Console = memo(({ prompt, commandService, ...commonProps }) => { + const consoleWindowRef = useRef(null); + const inputFocusRef: CommandInputProps['focusRef'] = useRef(null); + const getTestId = useTestIdGenerator(commonProps['data-test-subj']); + + const scrollToBottom = useCallback(() => { + // We need the `setTimeout` here because in some cases, the command output + // will take a bit of time to populate its content due to the use of Promises + setTimeout(() => { + if (consoleWindowRef.current) { + consoleWindowRef.current.scrollTop = consoleWindowRef.current.scrollHeight; + } + }, 1); + + // NOTE: its IMPORTANT that this callback does NOT have any dependencies, because + // it is stored in State and currently not updated if it changes + }, []); + + const handleConsoleClick = useCallback(() => { + if (inputFocusRef.current) { + inputFocusRef.current(); + } + }, []); + + return ( + + + + + + + + + + + + + + + ); +}); + +Console.displayName = 'Console'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts new file mode 100644 index 0000000000000..22167d5066743 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.builtinCommandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts new file mode 100644 index 0000000000000..ded51471a1c3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; + +export const useCommandHistory = () => { + return useConsoleStore().state.commandHistory; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts new file mode 100644 index 0000000000000..66ce0c2b5eb43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.commandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts new file mode 100644 index 0000000000000..90e5fe094f9c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { ConsoleStore } from '../../components/console_state/types'; + +export const useConsoleStateDispatch = (): ConsoleStore['dispatch'] => { + return useConsoleStore().dispatch; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts new file mode 100644 index 0000000000000..144a5a63cd71b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; + +export const useDataTestSubj = (): string | undefined => { + return useConsoleStore().state.dataTestSubj; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts new file mode 100644 index 0000000000000..81244b3013b36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Console } from './console'; +export type { ConsoleProps } from './console'; +export type { CommandServiceInterface, CommandDefinition, Command } from './types'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx new file mode 100644 index 0000000000000..693daf83ed6ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { Console } from './console'; +import type { ConsoleProps } from './console'; +import type { Command, CommandServiceInterface } from './types'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { CommandDefinition } from './types'; + +export interface ConsoleTestSetup { + renderConsole(props?: Partial): ReturnType; + + commandServiceMock: jest.Mocked; + + enterCommand( + cmd: string, + options?: Partial<{ + /** If true, the ENTER key will not be pressed */ + inputOnly: boolean; + /** + * if true, then the keyboard keys will be used to send the command. + * Use this if wanting ot press keyboard keys other than letter/punctuation + */ + useKeyboard: boolean; + }> + ): void; +} + +export const getConsoleTestSetup = (): ConsoleTestSetup => { + const mockedContext = createAppRootMockRenderer(); + + let renderResult: ReturnType; + + const commandServiceMock = getCommandServiceMock(); + + const renderConsole: ConsoleTestSetup['renderConsole'] = ({ + prompt = '$$>', + commandService = commandServiceMock, + 'data-test-subj': dataTestSubj = 'test', + ...others + } = {}) => { + if (commandService !== commandServiceMock) { + throw new Error('Must use CommandService provided by test setup'); + } + + return (renderResult = mockedContext.render( + + )); + }; + + const enterCommand: ConsoleTestSetup['enterCommand'] = ( + cmd, + { inputOnly = false, useKeyboard = false } = {} + ) => { + const keyCaptureInput = renderResult.getByTestId('test-keyCapture-input'); + + act(() => { + if (useKeyboard) { + userEvent.click(keyCaptureInput); + userEvent.keyboard(cmd); + } else { + userEvent.type(keyCaptureInput, cmd); + } + + if (!inputOnly) { + userEvent.keyboard('{enter}'); + } + }); + }; + + return { + renderConsole, + commandServiceMock, + enterCommand, + }; +}; + +export const getCommandServiceMock = (): jest.Mocked => { + return { + getCommandList: jest.fn(() => { + const commands: CommandDefinition[] = [ + { + name: 'cmd1', + about: 'a command with no options', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + about: 'Includes file in the run', + required: true, + allowMultiples: false, + validate: () => { + return true; + }, + }, + ext: { + about: 'optional argument', + required: false, + allowMultiples: false, + }, + bad: { + about: 'will fail validation', + required: false, + allowMultiples: false, + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd3', + about: 'allows argument to be used multiple times', + args: { + foo: { + about: 'foo stuff', + required: true, + allowMultiples: true, + }, + }, + }, + { + name: 'cmd4', + about: 'all options optinal, but at least one is required', + mustHaveArgs: true, + args: { + foo: { + about: 'foo stuff', + required: false, + allowMultiples: true, + }, + bar: { + about: 'bar stuff', + required: false, + allowMultiples: true, + }, + }, + }, + ]; + + return commands; + }), + + executeCommand: jest.fn(async (command: Command) => { + await new Promise((r) => setTimeout(r, 1)); + + return { + result: ( +
    +
    {`${command.commandDefinition.name}`}
    +
    {`command input: ${command.input}`}
    + + {JSON.stringify(command.args, null, 2)} + +
    + ), + }; + }), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx new file mode 100644 index 0000000000000..6cd8af0dc6eff --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { HistoryItem, HistoryItemComponent } from '../components/history_item'; +import { HelpOutput } from '../components/help_output'; +import { ParsedCommandInput } from './parsed_command_input'; +import { CommandList } from '../components/command_list'; +import { CommandUsage } from '../components/command_usage'; +import { Command, CommandDefinition, CommandServiceInterface } from '../types'; +import { BuiltinCommandServiceInterface } from './types.builtin_command_service'; + +const builtInCommands = (): CommandDefinition[] => { + return [ + { + name: 'help', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { + defaultMessage: 'View list of available commands', + }), + }, + { + name: 'clear', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { + defaultMessage: 'Clear the console buffer', + }), + }, + ]; +}; + +export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface { + constructor(private commandList = builtInCommands()) {} + + getCommandList(): CommandDefinition[] { + return this.commandList; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { + result: null, + }; + } + + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean } { + switch (parsedInput.name) { + case 'help': + return { + result: ( + + + {this.getHelpContent(parsedInput, contextConsoleService)} + + + ), + }; + + case 'clear': + return { + result: null, + clearBuffer: true, + }; + } + + return { result: null }; + } + + async getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }> { + let helpOutput: ReactNode; + + if (commandService.getHelp) { + helpOutput = (await commandService.getHelp()).result; + } else { + helpOutput = ( + + ); + } + + return { + result: helpOutput, + }; + } + + isBuiltin(name: string): boolean { + return !!this.commandList.find((command) => command.name === name); + } + + async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> { + return { + result: , + }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts new file mode 100644 index 0000000000000..55e0b3dc6267b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import argsplit from 'argsplit'; + +// FIXME:PT use a 3rd party lib for arguments parsing +// For now, just using what I found in kibana package.json devDependencies, so this will NOT work for production + +// FIXME:PT Type `ParsedCommandInput` should be a generic that allows for the args's keys to be defined + +export interface ParsedArgData { + /** For arguments that were used only once. Will be `undefined` if multiples were used */ + value: undefined | string; + /** For arguments that were used multiple times */ + values: undefined | string[]; +} + +export interface ParsedCommandInput { + input: string; + name: string; + args: { + [argName: string]: ParsedArgData; + }; + unknownArgs: undefined | string[]; + hasArgs(): boolean; + hasArg(argName: string): boolean; +} + +const PARSED_COMMAND_INPUT_PROTOTYPE: Pick = Object.freeze({ + hasArgs(this: ParsedCommandInput) { + return Object.keys(this.args).length > 0 || Array.isArray(this.unknownArgs); + }, + + hasArg(argName: string): boolean { + // @ts-ignore + return Object.prototype.hasOwnProperty.call(this.args, argName); + }, +}); + +export const parseCommandInput = (input: string): ParsedCommandInput => { + const inputTokens: string[] = argsplit(input) || []; + const name: string = inputTokens.shift() || ''; + const args: ParsedCommandInput['args'] = {}; + let unknownArgs: ParsedCommandInput['unknownArgs']; + + // All options start with `--` + let argName = ''; + + for (const inputToken of inputTokens) { + if (inputToken.startsWith('--')) { + argName = inputToken.substr(2); + + if (!args[argName]) { + args[argName] = { + value: undefined, + values: undefined, + }; + } + + // eslint-disable-next-line no-continue + continue; + } else if (!argName) { + (unknownArgs = unknownArgs || []).push(inputToken); + + // eslint-disable-next-line no-continue + continue; + } + + if (Array.isArray(args[argName].values)) { + // @ts-ignore + args[argName].values.push(inputToken); + } else { + // Do we have multiple values for this argumentName, then create array for values + if (args[argName].value !== undefined) { + args[argName].values = [args[argName].value ?? '', inputToken]; + args[argName].value = undefined; + } else { + args[argName].value = inputToken; + } + } + } + + return Object.assign(Object.create(PARSED_COMMAND_INPUT_PROTOTYPE), { + input, + name, + args, + unknownArgs, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts new file mode 100644 index 0000000000000..dbd5347ea99c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; +import { CommandDefinition, CommandServiceInterface } from '../types'; +import { ParsedCommandInput } from './parsed_command_input'; +import { HistoryItemComponent } from '../components/history_item'; + +export interface BuiltinCommandServiceInterface extends CommandServiceInterface { + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean }; + + getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }>; + + isBuiltin(name: string): boolean; + + getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts new file mode 100644 index 0000000000000..edc7d404fd8dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CommandDefinition } from '../types'; + +export const usageFromCommandDefinition = (command: CommandDefinition): string => { + let requiredArgs = ''; + let optionalArgs = ''; + + if (command.args) { + for (const [argName, argDefinition] of Object.entries(command.args)) { + if (argDefinition.required) { + if (requiredArgs.length) { + requiredArgs += ' '; + } + requiredArgs += `--${argName}`; + } else { + if (optionalArgs.length) { + optionalArgs += ' '; + } + optionalArgs += `--${argName}`; + } + } + } + + return `${command.name} ${requiredArgs} ${ + optionalArgs.length > 0 ? `[${optionalArgs}]` : '' + }`.trim(); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts new file mode 100644 index 0000000000000..e2b6d5c2a84aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; +import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; + +export interface CommandDefinition { + name: string; + about: string; + validator?: () => Promise; + /** If all args are optional, but at least one must be defined, set to true */ + mustHaveArgs?: boolean; + args?: { + [longName: string]: { + required: boolean; + allowMultiples: boolean; + about: string; + /** + * Validate the individual values given to this argument. + * Should return `true` if valid or a string with the error message + */ + validate?: (argData: ParsedArgData) => true | string; + // Selector: Idea is that the schema can plugin in a rich component for the + // user to select something (ex. a file) + // FIXME: implement selector + selector?: () => unknown; + }; + }; +} + +/** + * A command to be executed (as entered by the user) + */ +export interface Command { + /** The raw input entered by the user */ + input: string; + // FIXME:PT this should be a generic that allows for the arguments type to be used + /** An object with the arguments entered by the user and their value */ + args: ParsedCommandInput; + /** The command defined associated with this user command */ + commandDefinition: CommandDefinition; +} + +export interface CommandServiceInterface { + getCommandList(): CommandDefinition[]; + + executeCommand(command: Command): Promise<{ result: ReactNode }>; + + /** + * If defined, then the `help` builtin command will display this output instead of the default one + * which is generated out of the Command list + */ + getHelp?: () => Promise<{ result: ReactNode }>; + + /** + * If defined, then the output of this function will be used to display individual + * command help (`--help`) + */ + getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx new file mode 100644 index 0000000000000..28472e123380a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { Console } from '../console'; +import { EndpointConsoleCommandService } from './endpoint_console_command_service'; +import type { HostMetadata } from '../../../../common/endpoint/types'; + +export interface EndpointConsoleProps { + endpoint: HostMetadata; +} + +export const EndpointConsole = memo((props) => { + const consoleService = useMemo(() => { + return new EndpointConsoleCommandService(); + }, []); + + return `} commandService={consoleService} />; +}); + +EndpointConsole.displayName = 'EndpointConsole'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx new file mode 100644 index 0000000000000..5028879bc1a49 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { CommandServiceInterface, CommandDefinition, Command } from '../console'; + +/** + * Endpoint specific Response Actions (commands) for use with Console. + */ +export class EndpointConsoleCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return []; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { result: <> }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts new file mode 100644 index 0000000000000..97f7fb61ae607 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EndpointConsole } from './endpoint_console'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx new file mode 100644 index 0000000000000..7fb057809919e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiCode } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useUrlParams } from '../../../components/hooks/use_url_params'; +import { + Command, + CommandDefinition, + CommandServiceInterface, + Console, +} from '../../../components/console'; + +const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms)); + +class DevCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return [ + { + name: 'cmd1', + about: 'Runs cmd1', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + required: true, + allowMultiples: false, + about: 'Includes file in the run', + validate: () => { + return true; + }, + }, + bad: { + required: false, + allowMultiples: false, + about: 'will fail validation', + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd-long-delay', + about: 'runs cmd 2', + }, + ]; + } + + async executeCommand(command: Command): Promise<{ result: React.ReactNode }> { + await delay(); + + if (command.commandDefinition.name === 'cmd-long-delay') { + await delay(20000); + } + + return { + result: ( +
    +
    {`${command.commandDefinition.name}`}
    +
    {`command input: ${command.input}`}
    + {JSON.stringify(command.args, null, 2)} +
    + ), + }; + } +} + +// ------------------------------------------------------------ +// FOR DEV PURPOSES ONLY +// FIXME:PT Delete once we have support via row actions menu +// ------------------------------------------------------------ +export const DevConsole = memo(() => { + const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled'); + + const consoleService = useMemo(() => { + return new DevCommandService(); + }, []); + + const { + urlParams: { showConsole = false }, + } = useUrlParams(); + + return isConsoleEnabled && showConsole ? ( +
    + +
    + ) : null; +}); +DevConsole.displayName = 'DevConsole'; 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 da6f3b54323c5..3946edb9a0981 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 @@ -68,6 +68,7 @@ import { BackToExternalAppButton, BackToExternalAppButtonProps, } from '../../../components/back_to_external_app_button/back_to_external_app_button'; +import { DevConsole } from './dev_console'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; @@ -664,6 +665,9 @@ export const EndpointList = () => { } headerBackComponent={routeState.backLink && backToPolicyList} > + {/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */} + + {hasSelectedEndpoint && } <> {areEndpointsEnrolling && !hasErrorFindingTotals && ( From df9c1f4f837eeaac39a1fdb6a3507747a24cbd09 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:56:02 +0100 Subject: [PATCH 53/66] [Workplace Search] Add documentation for external connector (#128414) * [Workplace Search] Add documentation for external connector --- .../external_connector_config.test.tsx | 10 +-- .../external_connector_config.tsx | 25 ++++--- .../external_connector_documentation.test.tsx | 24 +++++++ .../external_connector_documentation.tsx | 68 +++++++++++++++++++ .../external_connector_form_fields.test.tsx | 4 +- .../external_connector_form_fields.tsx | 0 .../external_connector_logic.test.ts | 11 +-- .../external_connector_logic.ts | 12 ++-- .../add_external_connector/index.ts | 11 +++ .../add_source/add_source_logic.test.ts | 2 +- .../components/add_source/add_source_logic.ts | 5 +- .../add_source/configuration_choice.tsx | 24 ++++--- .../add_source/save_config.test.tsx | 2 +- .../components/add_source/save_config.tsx | 9 ++- .../views/content_sources/source_data.tsx | 63 ++++++++--------- .../views/content_sources/sources_router.tsx | 2 +- 16 files changed, 200 insertions(+), 72 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_config.test.tsx (88%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_config.tsx (81%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_form_fields.test.tsx (95%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_form_fields.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_logic.test.ts (97%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_logic.ts (94%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx similarity index 88% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 6a93291a28cb3..4917877c0ec30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; +} from '../../../../../components/layout'; +import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 637be68929ac0..002cafa2e3229 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -21,17 +21,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, -} from '../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../constants'; -import { SourceDataItem } from '../../../../types'; +} from '../../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../../constants'; +import { SourceDataItem } from '../../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { ConfigDocsLinks } from './config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { staticExternalSourceData } from '../../../source_data'; + +import { AddSourceHeader } from './../add_source_header'; +import { ConfigDocsLinks } from './../config_docs_links'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants'; +import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; @@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC = ({ const { name, categories } = sourceConfigData; const { - configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl }, + configuration: { applicationLinkTitle, applicationPortalUrl }, } = sourceData; const { isOrganization } = useValues(AppLogic); + const { + configuration: { documentationUrl }, + } = staticExternalSourceData; + const saveButton = ( {OAUTH_SAVE_CONFIG_BUTTON} @@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({ {header} + +
    diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx new file mode 100644 index 0000000000000..13b8967637ee1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { ExternalConnectorDocumentation } from './external_connector_documentation'; + +describe('ExternalDocumentation', () => { + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx new file mode 100644 index 0000000000000..437bf6f683198 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText, EuiLink } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface ExternalConnectorDocumentationProps { + name: string; + documentationUrl: string; +} + +export const ExternalConnectorDocumentation: React.FC = ({ + name, + documentationUrl, +}) => { + return ( + +

    + +

    +

    + + + + ), + }} + /> +

    +

    + + + +

    +

    + + + +

    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx similarity index 95% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx index 931a2f3517fbb..45a7dd122eabf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index 38bf74052541c..0e9ad386a353d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -10,18 +10,19 @@ import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues, -} from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +} from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; -jest.mock('../../../../app_logic', () => ({ +jest.mock('../../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; + import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; describe('ExternalConnectorLogic', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index 1f7edf0d8e2a9..3bf96a31dd8c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -13,14 +13,14 @@ import { flashAPIErrors, flashSuccessToast, clearFlashMessages, -} from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; -import { KibanaLogic } from '../../../../../shared/kibana'; -import { AppLogic } from '../../../../app_logic'; +} from '../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../shared/http'; +import { KibanaLogic } from '../../../../../../shared/kibana'; +import { AppLogic } from '../../../../../app_logic'; -import { getAddPath, getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../../routes'; -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; export interface ExternalConnectorActions { fetchExternalSource: () => true; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts new file mode 100644 index 0000000000000..7f2871a9f5c75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExternalConnectorConfig } from './external_connector_config'; +export { ExternalConnectorFormFields } from './external_connector_form_fields'; +export { ExternalConnectorLogic } from './external_connector_logic'; +export { ExternalConnectorDocumentation } from './external_connector_documentation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 21246defbb863..6b335b1f7ffe4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; +import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; import { AddSourceLogic, AddSourceSteps, @@ -38,7 +39,6 @@ import { AddSourceValues, AddSourceProps, } from './add_source_logic'; -import { ExternalConnectorLogic } from './external_connector_logic'; describe('AddSourceLogic', () => { const { mount } = new LogicMounter(AddSourceLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 8693cffc17e21..c621e0ee16bd5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; -import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic'; +import { + ExternalConnectorLogic, + isValidExternalUrl, +} from './add_external_connector/external_connector_logic'; export interface AddSourceProps { sourceData: SourceDataItem; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 9a5673451cd1a..8d8311d2a0a6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -30,7 +30,7 @@ interface CardProps { description: string; buttonText: string; onClick: () => void; - betaBadgeLabel?: string; + badgeLabel?: string; } export const ConfigurationChoice: React.FC = ({ @@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({ description, buttonText, onClick, - betaBadgeLabel, + badgeLabel, }: CardProps) => ( {buttonText} @@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', { - defaultMessage: 'Default connector', + defaultMessage: 'Connector', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', { - defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + defaultMessage: + 'Use this connector to get started quickly without deploying additional infrastructure.', } ), buttonText: i18n.translate( @@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Connect', } ), + badgeLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel', + { + defaultMessage: 'Recommended', + } + ), onClick: goToInternal, }; @@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', { - defaultMessage: 'Custom connector', + defaultMessage: 'Connector Package', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', { - defaultMessage: 'Set up a custom connector for more configurability and control.', + defaultMessage: + 'Deploy this connector package on self-managed infrastructure for advanced use cases.', } ), buttonText: i18n.translate( @@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({ } ), onClick: goToExternal, - betaBadgeLabel: i18n.translate( + badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { defaultMessage: 'Beta', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx index 5c234be583b9d..3e35c608fcee8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx @@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { ApiKey } from '../../../../components/shared/api_key'; import { staticSourceData } from '../../source_data'; +import { ExternalConnectorFormFields } from './add_external_connector'; import { ConfigDocsLinks } from './config_docs_links'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { SaveConfig } from './save_config'; describe('SaveConfig', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index d56efcdab95d6..eb887a9f8cc42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -35,10 +35,11 @@ import { } from '../../../../constants'; import { Configuration } from '../../../../types'; +import { ExternalConnectorFormFields } from './add_external_connector'; +import { ExternalConnectorDocumentation } from './add_external_connector'; import { AddSourceLogic } from './add_source_logic'; import { ConfigDocsLinks } from './config_docs_links'; import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; interface SaveConfigProps { header: React.ReactNode; @@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({ <> {header} + {serviceType === 'external' && ( + <> + + + + )}
    diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 361eccbe8da38..5b1e4d97ef4cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; +export const staticExternalSourceData: SourceDataItem = { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + }, + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + isBeta: true, +}; + export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, @@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [ internalConnectorAvailable: true, externalConnectorAvailable: true, }, - // TODO: temporary hack until backend sends us stuff - { - name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, - serviceType: 'external', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, - applicationPortalUrl: 'https://portal.azure.com/', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, - isBeta: true, - }, + staticExternalSourceData, { name: SOURCE_NAMES.SHAREPOINT_SERVER, iconName: SOURCE_NAMES.SHAREPOINT_SERVER, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index e735119f687cc..19af955f8780c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; import { ConfigurationChoice } from './components/add_source/configuration_choice'; -import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; import { staticCustomSourceData, staticSourceData as sources } from './source_data'; From f69fe77413bd065dd5e5a537878d2f0e00d981f5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 24 Mar 2022 09:32:07 -0400 Subject: [PATCH 54/66] skip failing test suite (#128468) --- x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index 66d1e83700ded..40fd69246710b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -16,7 +16,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const retry = getService('retry'); const browser = getService('browser'); - describe('cases list', () => { + // Failing: See https://github.com/elastic/kibana/issues/128468 + describe.skip('cases list', () => { before(async () => { await common.navigateToApp('cases'); await cases.api.deleteAllCases(); From 78bae9b24140294b4dbfae379ea588987f08350d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 24 Mar 2022 14:32:53 +0100 Subject: [PATCH 55/66] [Security solution] [Endpoint] Add api validations for single blocklist actions (#128252) * Adds blocklists extension point validation for Lists Api * Fixes generator and adds ftr test for blocklist validator under Lists API * Adds js comments on hash validation method * Removes trusted entry for signed field. Change process. by file. Fixed typo and updated generator and ftr test * Reenable disabled ftr tests * Fix wrong hash types in generator. Improve blocklists validator and updated/added tests in ftr test * Fixes Blocklist validator using file.path field, also fixed generator and unit test for the same * Returns original updated item to avoid unnecessary casting --- .../exceptions_list_item_generator.ts | 50 ++- .../services/feature_usage/service.ts | 1 + .../handlers/exceptions_pre_create_handler.ts | 9 + .../exceptions_pre_delete_item_handler.ts | 7 + .../handlers/exceptions_pre_export_handler.ts | 8 + .../exceptions_pre_get_one_handler.ts | 7 + .../exceptions_pre_multi_list_find_handler.ts | 8 + ...exceptions_pre_single_list_find_handler.ts | 9 +- .../exceptions_pre_summary_handler.ts | 8 + .../handlers/exceptions_pre_update_handler.ts | 12 + .../validators/blocklist_validator.ts | 279 ++++++++++++++ .../endpoint/validators/index.ts | 1 + .../validators/trusted_app_validator.ts | 2 +- .../apis/endpoint_artifacts/blocklists.ts | 343 ++++++++++++++++++ .../apis/index.ts | 1 + 15 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index e6f2669c95c34..737d81cc9d1ed 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -256,19 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator TrustedAppValidator.isTrustedApp({ listId: id }))) { await new TrustedAppValidator(endpointAppContextService, request).validatePreMultiListFind(); @@ -46,6 +48,12 @@ export const getExceptionsPreMultiListFindHandler = ( return data; } + // validate Blocklist + if (data.listId.some((id) => BlocklistValidator.isBlocklist({ listId: id }))) { + await new BlocklistValidator(endpointAppContextService, request).validatePreMultiListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts index c33ae013b2099..917e6c97b1bfd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback']; @@ -24,7 +25,7 @@ export const getExceptionsPreSingleListFindHandler = ( const { listId } = data; - // Validate Host Isolation Exceptions + // Validate Trusted applications if (TrustedAppValidator.isTrustedApp({ listId })) { await new TrustedAppValidator(endpointAppContextService, request).validatePreSingleListFind(); return data; @@ -48,6 +49,12 @@ export const getExceptionsPreSingleListFindHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreSingleListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts index c250979058962..93c1abdcb7d7a 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreSummaryHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreGetListSummary(); return data; } + // Host Isolation Exceptions if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreSummaryHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetListSummary(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 67b2e5cc03efe..acedbf7d1ed25 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -15,6 +15,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback']; @@ -86,6 +87,17 @@ export const getExceptionsPreUpdateItemHandler = ( return validatedItem; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + const blocklistValidator = new BlocklistValidator(endpointAppContextService, request); + const validatedItem = await blocklistValidator.validatePreUpdateItem(data, currentSavedItem); + blocklistValidator.notifyFeatureUsage( + data as ExceptionItemLikeOptions, + 'BLOCKLIST_BY_POLICY' + ); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts new file mode 100644 index 0000000000000..e51190467aee4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -0,0 +1,279 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { isValidHash } from '../../../../common/endpoint/service/trusted_apps/validations'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const allowedHashes: Readonly = ['file.hash.md5', 'file.hash.sha1', 'file.hash.sha256']; + +const FileHashField = schema.oneOf( + allowedHashes.map((hash) => schema.literal(hash)) as [Type] +); + +const FilePath = schema.literal('file.path'); +const FileCodeSigner = schema.literal('file.Ext.code_signature'); + +const ConditionEntryTypeSchema = schema.literal('match_any'); +const ConditionEntryOperatorSchema = schema.literal('included'); + +type ConditionEntryFieldAllowedType = + | TypeOf + | TypeOf + | TypeOf; + +type BlocklistConditionEntry = + | { + field: ConditionEntryFieldAllowedType; + type: 'match_any'; + operator: 'included'; + value: string[]; + } + | TypeOf; + +/* + * A generic Entry schema to be used for a specific entry schema depending on the OS + */ +const CommonEntrySchema = { + field: schema.oneOf([FileHashField, FilePath]), + type: ConditionEntryTypeSchema, + operator: ConditionEntryOperatorSchema, + // If field === HASH then validate hash with custom method, else validate string with minLength = 1 + value: schema.conditional( + schema.siblingRef('field'), + FileHashField, + schema.arrayOf( + schema.string({ + validate: (hash: string) => + isValidHash(hash) ? undefined : `invalid hash value [${hash}]`, + }), + { minSize: 1 } + ), + schema.conditional( + schema.siblingRef('field'), + FilePath, + schema.arrayOf( + schema.string({ + validate: (pathValue: string) => + pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`, + }), + { minSize: 1 } + ), + schema.arrayOf( + schema.string({ + validate: (signerValue: string) => + signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`, + }), + { minSize: 1 } + ) + ) + ), +}; + +// Windows Signer entries use a Nested field that checks to ensure +// that the certificate is trusted +const WindowsSignerEntrySchema = schema.object({ + type: schema.literal('nested'), + field: FileCodeSigner, + entries: schema.arrayOf( + schema.object({ + field: schema.literal('subject_name'), + value: schema.arrayOf(schema.string({ minLength: 1 })), + type: schema.literal('match_any'), + operator: schema.literal('included'), + }), + { minSize: 1 } + ), +}); + +const WindowsEntrySchema = schema.oneOf([ + WindowsSignerEntrySchema, + schema.object({ + ...CommonEntrySchema, + field: schema.oneOf([FileHashField, FilePath]), + }), +]); + +const LinuxEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const MacEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +// Hash entries validator method. +const hashEntriesValidation = (entries: BlocklistConditionEntry[]) => { + const currentHashes = entries.map((entry) => entry.field); + // If there are more hashes than allowed (three) then return an error + if (currentHashes.length > allowedHashes.length) { + const allowedHashesMessage = allowedHashes + .map((hash) => hash.replace('file.hash.', '')) + .join(','); + return `There are more hash types than allowed [${allowedHashesMessage}]`; + } + + const hashesCount: { [key: string]: boolean } = {}; + const duplicatedHashes: string[] = []; + const invalidHash: string[] = []; + + // Check hash entries individually + currentHashes.forEach((hash) => { + if (!allowedHashes.includes(hash)) invalidHash.push(hash); + if (hashesCount[hash]) { + duplicatedHashes.push(hash); + } else { + hashesCount[hash] = true; + } + }); + + // There is more than one entry with the same hash type + if (duplicatedHashes.length) { + return `There are some duplicated hashes: ${duplicatedHashes.join(',')}`; + } + + // There is an entry with an invalid hash type + if (invalidHash.length) { + return `There are some invalid fields for hash type: ${invalidHash.join(',')}`; + } +}; + +// Validate there is only one entry when signer or path and the allowed entries for hashes +const entriesSchemaOptions = { + minSize: 1, + validate(entries: BlocklistConditionEntry[]) { + if (allowedHashes.includes(entries[0].field)) { + return hashEntriesValidation(entries); + } else { + if (entries.length > 1) { + return 'Only one entry is allowed when no using hash field type'; + } + } + }, +}; + +/* + * Entities array schema depending on Os type using schema.conditional. + * If OS === WINDOWS then use Windows schema, + * else if OS === LINUX then use Linux schema, + * else use Mac schema + * + * The validate function checks there is only one item for entries excepts for hash + */ +const EntriesSchema = schema.conditional( + schema.contextRef('os'), + OperatingSystem.WINDOWS, + schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions), + schema.conditional( + schema.contextRef('os'), + OperatingSystem.LINUX, + schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions), + schema.arrayOf(MacEntrySchema, entriesSchemaOptions) + ) +); + +/** + * Schema to validate Blocklist data for create and update. + * When called, it must be given an `context` with a `os` property set + * + * @example + * + * BlocklistDataSchema.validate(item, { os: 'windows' }); + */ +const BlocklistDataSchema = schema.object( + { + entries: EntriesSchema, + }, + + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +export class BlocklistValidator extends BaseValidator { + static isBlocklist(item: { listId: string }): boolean { + return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; + } + + async validatePreCreateItem( + item: CreateExceptionListItemOptions + ): Promise { + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(item); + await this.validateCanCreateByPolicyArtifacts(item); + await this.validateByPolicyItem(item); + + return item; + } + + async validatePreDeleteItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetOneItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreMultiListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreExport(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreSingleListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetListSummary(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreUpdateItem( + _updatedItem: UpdateExceptionListItemOptions, + currentItem: ExceptionListItemSchema + ): Promise { + const updatedItem = _updatedItem as ExceptionItemLikeOptions; + + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(updatedItem); + + try { + await this.validateCanCreateByPolicyArtifacts(updatedItem); + } catch (noByPolicyAuthzError) { + // Not allowed to create/update by policy data. Validate that the effective scope of the item + // remained unchanged with this update or was set to `global` (only allowed update). If not, + // then throw the validation error that was catch'ed + if (this.wasByPolicyEffectScopeChanged(updatedItem, currentItem)) { + throw noByPolicyAuthzError; + } + } + + await this.validateByPolicyItem(updatedItem); + + return _updatedItem; + } + + private async validateBlocklistData(item: ExceptionItemLikeOptions): Promise { + await this.validateBasicData(item); + + try { + BlocklistDataSchema.validate(item, { os: item.osTypes[0] }); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts index 05b3847001869..ccd6ebd8e08d6 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -8,3 +8,4 @@ export { TrustedAppValidator } from './trusted_app_validator'; export { EventFilterValidator } from './event_filter_validator'; export { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator'; +export { BlocklistValidator } from './blocklist_validator'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index dc539e76e7946..b2171ebd018bd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -230,7 +230,7 @@ export class TrustedAppValidator extends BaseValidator { await this.validateByPolicyItem(updatedItem); - return updatedItem as UpdateExceptionListItemOptions; + return _updatedItem; } private async validateTrustedAppData(item: ExceptionItemLikeOptions): Promise { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts new file mode 100644 index 0000000000000..7e67c38347603 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../../security_solution_endpoint/services/endpoint_policy'; +import { ArtifactTestData } from '../../../security_solution_endpoint/services//endpoint_artifacts'; +import { + BY_POLICY_ARTIFACT_TAG_PREFIX, + GLOBAL_ARTIFACT_TAG, +} from '../../../../plugins/security_solution/common/endpoint/service/artifacts'; +import { ExceptionsListItemGenerator } from '../../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { + createUserAndRole, + deleteUserAndRole, + ROLES, +} from '../../../common/services/security_solution'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + describe('Endpoint artifacts (via lists plugin): Blocklists', () => { + let fleetEndpointPolicy: PolicyTestResourceInfo; + + before(async () => { + // Create an endpoint policy in fleet we can work with + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + + // create role/user + await createUserAndRole(getService, ROLES.detections_admin); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + + // delete role/user + await deleteUserAndRole(getService, ROLES.detections_admin); + }); + + const anEndpointArtifactError = (res: { body: { message: string } }) => { + expect(res.body.message).to.match(/EndpointArtifactError/); + }; + const anErrorMessageWith = ( + value: string | RegExp + ): ((res: { body: { message: string } }) => void) => { + return (res) => { + if (value instanceof RegExp) { + expect(res.body.message).to.match(value); + } else { + expect(res.body.message).to.be(value); + } + }; + }; + + describe('and accessing blocklists', () => { + const exceptionsGenerator = new ExceptionsListItemGenerator(); + let blocklistData: ArtifactTestData; + + type BlocklistApiCallsInterface = Array<{ + method: keyof Pick; + info?: string; + path: string; + // The body just needs to have the properties we care about in the tests. This should cover most + // mocks used for testing that support different interfaces + getBody: () => BodyReturnType; + }>; + + beforeEach(async () => { + blocklistData = await endpointArtifactTestResources.createBlocklist({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${fleetEndpointPolicy.packagePolicy.id}`], + }); + }); + + afterEach(async () => { + if (blocklistData) { + await blocklistData.cleanup(); + } + }); + + const blocklistApiCalls: BlocklistApiCallsInterface< + Pick + > = [ + { + method: 'post', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => { + return exceptionsGenerator.generateBlocklistForCreate({ tags: [GLOBAL_ARTIFACT_TAG] }); + }, + }, + { + method: 'put', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => + exceptionsGenerator.generateBlocklistForUpdate({ + id: blocklistData.artifact.id, + item_id: blocklistData.artifact.item_id, + tags: [GLOBAL_ARTIFACT_TAG], + }), + }, + ]; + + describe('and has authorization to manage endpoint security', () => { + for (const blocklistApiCall of blocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}] if invalid condition entry fields are used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries[0].field = 'some.invalid.field'; + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/types that failed validation:/)); + }); + + it(`should error on [${blocklistApiCall.method}] if the same hash type is present twice`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.sha256', + value: ['a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.hash.sha256', + value: [ + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/duplicated/)); + }); + + it(`should error on [${blocklistApiCall.method}] if an invalid hash is used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid hash/)); + }); + + it(`should error on [${blocklistApiCall.method}] if no values`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: [], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/Invalid value \"\[\]\"/)); + }); + + it(`should error on [${blocklistApiCall.method}] if signer is set for a non windows os entry item`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: 'foo', + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one entry and not a hash`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.path', + value: ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], + type: 'match_any', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/one entry is allowed/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one OS is set`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux', 'windows']; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/\[osTypes\]: array size is \[2\]/)); + }); + + it(`should error on [${blocklistApiCall.method}] if policy id is invalid`, async () => { + const body = blocklistApiCall.getBody(); + + body.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid policy ids/)); + }); + } + }); + + describe('and user DOES NOT have authorization to manage endpoint security', () => { + const allblocklistApiCalls: BlocklistApiCallsInterface = [ + ...blocklistApiCalls, + { + method: 'get', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'list summary', + get path() { + return `${EXCEPTION_LIST_URL}/summary?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'delete', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'post', + info: 'list export', + get path() { + return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=1`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'single items', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&page=1&per_page=1&sort_field=name&sort_order=asc`; + }, + getBody: () => undefined, + }, + ]; + + for (const blocklistApiCall of allblocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}]`, async () => { + await supertestWithoutAuth[blocklistApiCall.method](blocklistApiCall.path) + .auth(ROLES.detections_admin, 'changeme') + .set('kbn-xsrf', 'true') + .send(blocklistApiCall.getBody()) + .expect(403, { + status_code: 403, + message: 'EndpointArtifactError: Endpoint authorization failure', + }); + }); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 5acb9d2e4261d..94a5a9122f187 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -35,5 +35,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_artifacts/trusted_apps')); loadTestFile(require.resolve('./endpoint_artifacts/event_filters')); loadTestFile(require.resolve('./endpoint_artifacts/host_isolation_exceptions')); + loadTestFile(require.resolve('./endpoint_artifacts/blocklists')); }); } From 0421f868eaf92c65c78cf5b79db8502a223078dc Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 24 Mar 2022 14:37:11 +0100 Subject: [PATCH 56/66] [Search] SQL search strategy (#127859) --- .../search_examples/public/application.tsx | 9 + .../search_examples/public/sql_search/app.tsx | 164 +++++++++++ src/plugins/data/common/search/index.ts | 1 + .../search/strategies/sql_search/index.ts | 9 + .../search/strategies/sql_search/types.ts | 23 ++ src/plugins/data/server/search/README.md | 1 + .../data/server/search/search_service.ts | 3 + .../search/strategies/sql_search/index.ts | 9 + .../sql_search/request_utils.test.ts | 110 ++++++++ .../strategies/sql_search/request_utils.ts | 56 ++++ .../strategies/sql_search/response_utils.ts | 26 ++ .../sql_search/sql_search_strategy.test.ts | 264 ++++++++++++++++++ .../sql_search/sql_search_strategy.ts | 138 +++++++++ test/api_integration/apis/search/index.ts | 1 + .../api_integration/apis/search/sql_search.ts | 90 ++++++ .../search/session/get_search_status.ts | 1 + x-pack/test/examples/search_examples/index.ts | 1 + .../search_examples/sql_search_example.ts | 42 +++ 18 files changed, 948 insertions(+) create mode 100644 examples/search_examples/public/sql_search/app.tsx create mode 100644 src/plugins/data/common/search/strategies/sql_search/index.ts create mode 100644 src/plugins/data/common/search/strategies/sql_search/types.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/index.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/request_utils.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/response_utils.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts create mode 100644 test/api_integration/apis/search/sql_search.ts create mode 100644 x-pack/test/examples/search_examples/sql_search_example.ts diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx index c77c3c24be147..9bd5bb0f3f8a2 100644 --- a/examples/search_examples/public/application.tsx +++ b/examples/search_examples/public/application.tsx @@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page'; import { SearchExamplesApp } from './search/app'; import { SearchSessionsExampleApp } from './search_sessions/app'; import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public'; +import { SqlSearchExampleApp } from './sql_search/app'; const LINKS: ExampleLink[] = [ { path: '/search', title: 'Search', }, + { + path: '/sql-search', + title: 'SQL Search', + }, { path: '/search-sessions', title: 'Search Sessions', @@ -51,12 +56,16 @@ export const renderApp = ( /> + + + + diff --git a/examples/search_examples/public/sql_search/app.tsx b/examples/search_examples/public/sql_search/app.tsx new file mode 100644 index 0000000000000..acb640c4d82db --- /dev/null +++ b/examples/search_examples/public/sql_search/app.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPanel, + EuiSuperUpdateButton, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; + +import { + DataPublicPluginStart, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../src/plugins/data/public'; +import { + SQL_SEARCH_STRATEGY, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../src/plugins/data/common'; + +interface SearchExamplesAppDeps { + notifications: CoreStart['notifications']; + data: DataPublicPluginStart; +} + +export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => { + const [sqlQuery, setSqlQuery] = useState(''); + const [request, setRequest] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [rawResponse, setRawResponse] = useState>({}); + + function setResponse(response: IKibanaSearchResponse) { + setRawResponse(response.rawResponse); + } + + const doSearch = async () => { + const req: SqlSearchStrategyRequest = { + params: { + query: sqlQuery, + }, + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params!); + setIsLoading(true); + + data.search + .search(req, { + strategy: SQL_SEARCH_STRATEGY, + }) + .subscribe({ + next: (res) => { + if (isCompleteResponse(res)) { + setIsLoading(false); + setResponse(res); + } else if (isErrorResponse(res)) { + setIsLoading(false); + setResponse(res); + notifications.toasts.addDanger('An error has occurred'); + } + }, + error: (e) => { + setIsLoading(false); + data.search.showError(e); + }, + }); + }; + + return ( + + + +

    SQL search example

    +
    +
    + + + + + + setSqlQuery(e.target.value)} + fullWidth + data-test-subj="sqlQueryInput" + /> + + + + + + + + + + + +

    Request

    +
    + + {JSON.stringify(request, null, 2)} + +
    +
    + + + +

    Response

    +
    + + {JSON.stringify(rawResponse, null, 2)} + +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index badbb94e9752f..d0d103abe1ea2 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -17,3 +17,4 @@ export * from './poll_search'; export * from './strategies/es_search'; export * from './strategies/eql_search'; export * from './strategies/ese_search'; +export * from './strategies/sql_search'; diff --git a/src/plugins/data/common/search/strategies/sql_search/index.ts b/src/plugins/data/common/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/data/common/search/strategies/sql_search/types.ts b/src/plugins/data/common/search/strategies/sql_search/types.ts new file mode 100644 index 0000000000000..e51d0bf4a6b6c --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SqlGetAsyncRequest, + SqlQueryRequest, + SqlQueryResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types'; + +export const SQL_SEARCH_STRATEGY = 'sql'; + +export type SqlRequestParams = + | Omit + | Omit; +export type SqlSearchStrategyRequest = IKibanaSearchRequest; + +export type SqlSearchStrategyResponse = IKibanaSearchResponse; diff --git a/src/plugins/data/server/search/README.md b/src/plugins/data/server/search/README.md index b564c34a7f8b3..d663cdc38da1b 100644 --- a/src/plugins/data/server/search/README.md +++ b/src/plugins/data/server/search/README.md @@ -10,3 +10,4 @@ The `search` plugin includes: - ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL - (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions - EQL_SEARCH_STRATEGY +- SQL_SEARCH_STRATEGY diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 8fb92136bc259..7c01fefc92d65 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -77,6 +77,7 @@ import { eqlRawResponse, ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY, + SQL_SEARCH_STRATEGY, } from '../../common/search'; import { getEsaggs, getEsdsl, getEql } from './expressions'; import { @@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; import { CachedUiSettingsClient } from './services'; +import { sqlSearchStrategyProvider } from './strategies/sql_search'; type StrategyMap = Record>; @@ -176,6 +178,7 @@ export class SearchService implements Plugin { ); this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger)); + this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger)); registerBsearchRoute( bfetch, diff --git a/src/plugins/data/server/search/strategies/sql_search/index.ts b/src/plugins/data/server/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..9af70ddcb618d --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { sqlSearchStrategyProvider } from './sql_search_strategy'; diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts new file mode 100644 index 0000000000000..9944de7be17be --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts new file mode 100644 index 0000000000000..d05b2710b07ea --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchOptions } from '../../../../common'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +/** + @internal + */ +export function getDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts new file mode 100644 index 0000000000000..9d6e3f4fd3ebc --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SqlSearchStrategyResponse } from '../../../../common'; + +/** + * Get the Kibana representation of an async search response + */ +export function toAsyncKibanaSearchResponse( + response: SqlQueryResponse, + warning?: string +): SqlSearchStrategyResponse { + return { + id: response.id, + rawResponse: response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts new file mode 100644 index 0000000000000..2734a512e046b --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts @@ -0,0 +1,264 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KbnServerError } from '../../../../../kibana_utils/server'; +import { errors } from '@elastic/elasticsearch'; +import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json'; +import { SearchStrategyDependencies } from '../../types'; +import { sqlSearchStrategyProvider } from './sql_search_strategy'; +import { createSearchSessionsClientMock } from '../../mocks'; +import { SqlSearchStrategyRequest } from '../../../../common'; + +const mockSqlResponse = { + body: { + id: 'foo', + is_partial: false, + is_running: false, + rows: [], + }, +}; + +describe('SQL search strategy', () => { + const mockSqlGetAsync = jest.fn(); + const mockSqlQuery = jest.fn(); + const mockSqlDelete = jest.fn(); + const mockLogger: any = { + debug: () => {}, + }; + const mockDeps = { + esClient: { + asCurrentUser: { + sql: { + getAsync: mockSqlGetAsync, + query: mockSqlQuery, + deleteAsync: mockSqlDelete, + }, + }, + }, + searchSessionsClient: createSearchSessionsClientMock(), + } as unknown as SearchStrategyDependencies; + + beforeEach(() => { + mockSqlGetAsync.mockClear(); + mockSqlQuery.mockClear(); + mockSqlDelete.mockClear(); + }); + + it('returns a strategy with `search and `cancel`, `extend`', async () => { + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + expect(typeof esSearch.search).toBe('function'); + expect(typeof esSearch.cancel).toBe('function'); + expect(typeof esSearch.extend).toBe('function'); + }); + + describe('search', () => { + describe('no sessionId', () => { + it('makes a POST request with params when no ID provided', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, {}, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + expect(request).toHaveProperty('format', 'json'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + }); + + it('makes a GET request to async search with ID', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('format', 'json'); + }); + }); + + // skip until full search session support https://github.com/elastic/kibana/issues/127880 + describe.skip('with sessionId', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('keep_alive', '604800000ms'); + }); + + it('makes a GET request to async search without keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).not.toHaveProperty('keep_alive'); + }); + }); + + describe('with sessionId (until SQL ignores session Id)', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + + it('makes a GET request to async search with keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new errors.ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); + }); + + describe('cancel', () => { + it('makes a DELETE request to async search with the provided ID', async () => { + mockSqlDelete.mockResolvedValueOnce(200); + + const id = 'some_id'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.cancel!(id, {}, mockDeps); + + expect(mockSqlDelete).toBeCalled(); + const request = mockSqlDelete.mock.calls[0][0]; + expect(request).toEqual({ id }); + }); + }); + + describe('extend', () => { + it('makes a GET request to async search with the provided ID and keepAlive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + await esSearch.extend!(id, keepAlive, {}, mockDeps); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request).toEqual({ id, keep_alive: keepAlive }); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts new file mode 100644 index 0000000000000..51ab35af3db0f --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IScopedClusterClient, Logger } from 'kibana/server'; +import { catchError, tap } from 'rxjs/operators'; +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; +import type { + IAsyncSearchOptions, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../common'; +import { pollSearch } from '../../../../common'; +import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { getKbnServerError } from '../../../../../kibana_utils/server'; + +export const sqlSearchStrategyProvider = ( + logger: Logger, + useInternalUser: boolean = false +): ISearchStrategy => { + async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.deleteAsync({ id }); + } catch (e) { + throw getKbnServerError(e); + } + } + + function asyncSearch( + { id, ...request }: SqlSearchStrategyRequest, + options: IAsyncSearchOptions, + { esClient }: SearchStrategyDependencies + ) { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + + // disable search sessions until session task manager supports SQL + // https://github.com/elastic/kibana/issues/127880 + // const sessionConfig = searchSessionsClient.getConfig(); + const sessionConfig = null; + + const search = async () => { + if (id) { + const params: SqlGetAsyncRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncGetParams(sessionConfig, options), + id, + }; + + const { body, headers } = await client.sql.getAsync(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } else { + const params: SqlQueryRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncSubmitParams(sessionConfig, options), + ...request.params, + }; + + const { headers, body } = await client.sql.query(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } + }; + + const cancel = async () => { + if (id) { + await cancelAsyncSearch(id, esClient); + } + }; + + return pollSearch(search, cancel, options).pipe( + tap((response) => (id = response.id)), + catchError((e) => { + throw getKbnServerError(e); + }) + ); + } + + return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ + search: (request, options: IAsyncSearchOptions, deps) => { + logger.debug(`sql search: search request=${JSON.stringify(request)}`); + + return asyncSearch(request, options, deps); + }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + cancel: async (id, options, { esClient }) => { + logger.debug(`sql search: cancel async_search_id=${id}`); + await cancelAsyncSearch(id, esClient); + }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + extend: async (id, keepAlive, options, { esClient }) => { + logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`); + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.getAsync({ + id, + keep_alive: keepAlive, + }); + } catch (e) { + throw getKbnServerError(e); + } + }, + }; +}; diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index d5d6e928b5483..cde0c925d91ff 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./sql_search')); loadTestFile(require.resolve('./bsearch')); }); } diff --git a/test/api_integration/apis/search/sql_search.ts b/test/api_integration/apis/search/sql_search.ts new file mode 100644 index 0000000000000..c57d424e56fc7 --- /dev/null +++ b/test/api_integration/apis/search/sql_search.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + + describe('SQL search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + describe('post', () => { + it('should return 200 when correctly formatted searches are provided', async () => { + const resp = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + }, + }) + .expect(200); + + expect(resp.body).to.have.property('id'); + expect(resp.body).to.have.property('isPartial'); + expect(resp.body).to.have.property('isRunning'); + expect(resp.body).to.have.property('rawResponse'); + }); + + it('should fetch search results by id', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({}); + + expect(resp2.status).to.be(200); + expect(resp2.body.id).to.be(id); + expect(resp2.body).to.have.property('isPartial'); + expect(resp2.body).to.have.property('isRunning'); + expect(resp2.body).to.have.property('rawResponse'); + }); + }); + + describe('delete', () => { + it('should delete search', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + // confirm it was saved + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200); + + // delete it + await supertest.delete(`/internal/search/sql/${id}`).send().expect(200); + + // check it was deleted + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404); + }); + }); + }); +} diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index abfe089e82a38..aa8c2c0e3aa00 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -17,6 +17,7 @@ export async function getSearchStatus( asyncId: string ): Promise> { // TODO: Handle strategies other than the default one + // https://github.com/elastic/kibana/issues/127880 try { // @ts-expect-error start_time_in_millis: EpochMillis is string | number const apiResponse: TransportResult = await client.asyncSearch.status( diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index ac9e385d3d391..18b2acbd56564 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC loadTestFile(require.resolve('./search_example')); loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./partial_results_example')); + loadTestFile(require.resolve('./sql_search_example')); }); } diff --git a/x-pack/test/examples/search_examples/sql_search_example.ts b/x-pack/test/examples/search_examples/sql_search_example.ts new file mode 100644 index 0000000000000..a51ea21ea36bd --- /dev/null +++ b/x-pack/test/examples/search_examples/sql_search_example.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const toasts = getService('toasts'); + + describe('SQL search example', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + await testSubjects.click('/sql-search'); + }); + + it('should search', async () => { + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + await (await testSubjects.find('sqlQueryInput')).type(sqlQuery); + + await testSubjects.click(`querySubmitButton`); + + await testSubjects.stringExistsInCodeBlockOrFail( + 'requestCodeBlock', + JSON.stringify(sqlQuery) + ); + await testSubjects.stringExistsInCodeBlockOrFail( + 'responseCodeBlock', + `"logstash-2015.09.22"` + ); + expect(await toasts.getToastCount()).to.be(0); + }); + }); +} From 632d64e5d19be9ab84c23753328f07d0d5c7285c Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 24 Mar 2022 08:40:53 -0500 Subject: [PATCH 57/66] skip flaky suite. #128441 --- x-pack/plugins/task_manager/server/task_events.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts index 5d72120da725c..607453b7ea92f 100644 --- a/x-pack/plugins/task_manager/server/task_events.test.ts +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -45,7 +45,8 @@ describe('task_events', () => { expect(result.eventLoopBlockMs).toBe(undefined); }); - describe('startTaskTimerWithEventLoopMonitoring', () => { + // FLAKY: https://github.com/elastic/kibana/issues/128441 + describe.skip('startTaskTimerWithEventLoopMonitoring', () => { test('non-blocking', async () => { const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ monitor: true, From c591e46aa3d9c0623624a7b62cb629d215fe9b3b Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 24 Mar 2022 08:46:02 -0500 Subject: [PATCH 58/66] skip flaky suite. #126414 --- test/functional/apps/console/_autocomplete.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index cd17244b1f498..4b424b2a79c66 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - describe('with a missing comma in query', () => { + // FLAKY: https://github.com/elastic/kibana/issues/126414 + describe.skip('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); From a3e3ce81fe0a0dbb7e4eee527af71980e2872826 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 24 Mar 2022 09:53:13 -0400 Subject: [PATCH 59/66] [Alerting] Add UI indicators for rules with less than configured minimum schedule interval (#128254) * Adding warning icon next to rule interval * Adding rule type id and and id to warning * Changing to info icon. Showing toast on rule details view * Adding unit tests * Adding functional tests * Fixing unit tests * Fixing functional test * PR feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/rules_client/rules_client.ts | 49 +++---- .../server/rules_client/tests/create.test.ts | 2 +- .../server/rules_client/tests/update.test.ts | 2 +- .../components/rule_details.test.tsx | 23 ++++ .../rule_details/components/rule_details.tsx | 68 +++++++++- .../components/rule_details_route.test.tsx | 5 + .../rules_list/components/rules_list.test.tsx | 15 ++- .../rules_list/components/rules_list.tsx | 122 ++++++++++++++---- .../public/common/lib/config_api.ts | 7 +- .../triggers_actions_ui/public/types.ts | 1 + .../apps/triggers_actions_ui/alerts_list.ts | 25 +++- .../apps/triggers_actions_ui/details.ts | 33 ++++- x-pack/test/functional_with_es_ssl/config.ts | 2 +- 13 files changed, 299 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 666617dcf3fd8..02901ca3fdc70 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -368,19 +368,12 @@ export class RulesClient { await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be created - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -472,6 +465,14 @@ export class RulesClient { }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; } + + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${createdAlert.attributes.alertTypeId}" rule type with ID "${createdAlert.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + } + return this.getAlertFromRaw( createdAlert.id, createdAlert.attributes.alertTypeId, @@ -1117,19 +1118,12 @@ export class RulesClient { const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be updated - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -1192,6 +1186,13 @@ export class RulesClient { throw e; } + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + return this.getPartialRuleFromRaw( id, ruleType, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index df0e806e5e798..91be42ecd9e1f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -2602,7 +2602,7 @@ describe('create()', () => { await rulesClient.create({ data }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + `Rule schedule interval (1s) for "123" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); expect(taskManager.schedule).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index a087dfd436817..4bc0276a9ae1a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -1947,7 +1947,7 @@ describe('update()', () => { }, }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + `Rule schedule interval (1s) for "myType" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index e6b5fdbdb1883..dddfc357f2eaa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -31,6 +31,11 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); jest.mock('react-router-dom', () => ({ useHistory: () => ({ push: jest.fn(), @@ -142,6 +147,24 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); + describe('actions', () => { it('renders an rule action', () => { const rule = mockRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index de948c2fd21de..736178cc5ab3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,11 +27,18 @@ import { EuiPageTemplate, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AlertExecutionStatusErrorReasons, parseDuration } from '../../../../../../alerting/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getRuleDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; -import { Rule, RuleType, ActionType, ActionConnector } from '../../../../types'; +import { + Rule, + RuleType, + ActionType, + ActionConnector, + TriggersActionsUiConfig, +} from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkRuleOperations, @@ -47,6 +54,7 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; export type RuleDetailsProps = { rule: Rule; @@ -75,6 +83,7 @@ export const RuleDetails: React.FunctionComponent = ({ setBreadcrumbs, chrome, http, + notifications: { toasts }, } = useKibana().services; const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -84,6 +93,14 @@ export const RuleDetails: React.FunctionComponent = ({ const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); + const [config, setConfig] = useState({}); + + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + // Set breadcrumb and page title useEffect(() => { setBreadcrumbs([ @@ -141,6 +158,53 @@ export const RuleDetails: React.FunctionComponent = ({ const [dismissRuleErrors, setDismissRuleErrors] = useState(false); const [dismissRuleWarning, setDismissRuleWarning] = useState(false); + // Check whether interval is below configured minium + useEffect(() => { + if (rule.schedule.interval && config.minimumScheduleInterval) { + if ( + parseDuration(rule.schedule.interval) < parseDuration(config.minimumScheduleInterval.value) + ) { + const configurationToast = toasts.addInfo({ + 'data-test-subj': 'intervalConfigToast', + title: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.scheduleIntervalToastTitle', + { + defaultMessage: 'Configuration settings', + } + ), + text: toMountPoint( + <> +

    + +

    + {hasEditButton && ( + + + { + toasts.remove(configurationToast); + setEditFlyoutVisibility(true); + }} + > + + + + + )} + + ), + }); + } + } + }, [rule.schedule.interval, config.minimumScheduleInterval, toasts, hasEditButton]); + const setRule = async () => { history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx index 1289b81eb8169..032d69fa7ccc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx @@ -19,6 +19,11 @@ import { spacesPluginMock } from '../../../../../../spaces/public/mocks'; import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 36c102c6f54bb..021ea3c2d0055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -169,7 +169,7 @@ describe('rules_list component with items', () => { tags: ['tag1'], enabled: true, ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, + schedule: { interval: '1s' }, actions: [], params: { name: 'test rule type name' }, scheduledTaskId: null, @@ -476,6 +476,19 @@ describe('rules_list component with items', () => { wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-interval"]').length ).toEqual(mockedRulesData.length); + // Schedule interval tooltip + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe( + 'Below configured minimum intervalRule interval of 1 second is below the minimum configured interval of 1 minute. This may impact alerting performance.' + ); + + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOut'); + // Duration column expect( wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-duration"]').length diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index c55f1303120f0..3cb1ac7b93dca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -48,6 +48,7 @@ import { RuleTypeIndex, Pagination, Percentiles, + TriggersActionsUiConfig, } from '../../../../types'; import { RuleAdd, RuleEdit } from '../../rule_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -75,6 +76,7 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, formatDuration, + parseDuration, MONITORING_HISTORY_LIMIT, } from '../../../../../../alerting/common'; import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; @@ -89,6 +91,7 @@ import { PercentileSelectablePopover } from './percentile_selectable_popover'; import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; const ENTER_KEY = 13; @@ -135,6 +138,7 @@ export const RulesList: React.FunctionComponent = () => { const [initialLoad, setInitialLoad] = useState(true); const [noData, setNoData] = useState(true); + const [config, setConfig] = useState({}); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [isPerformingAction, setIsPerformingAction] = useState(false); @@ -150,6 +154,12 @@ export const RulesList: React.FunctionComponent = () => { const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -609,7 +619,59 @@ export const RulesList: React.FunctionComponent = () => { sortable: false, truncateText: false, 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string) => formatDuration(interval), + render: (interval: string, item: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {item.showIntervalWarning && ( + + { + if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { + onRuleEdit(item); + } + }} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, }, { field: 'executionStatus.lastDuration', @@ -850,11 +912,12 @@ export const RulesList: React.FunctionComponent = () => { setIsPerformingAction(true)} onActionPerformed={() => { loadRulesData(); @@ -1037,7 +1100,12 @@ export const RulesList: React.FunctionComponent = () => { items={ ruleTypesState.isInitialized === false ? [] - : convertRulesToTableItems(rulesState.data, ruleTypesState.data, canExecuteActions) + : convertRulesToTableItems({ + rules: rulesState.data, + ruleTypeIndex: ruleTypesState.data, + canExecuteActions, + config, + }) } itemId="id" columns={getRulesTableColumns()} @@ -1202,19 +1270,29 @@ function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } -function convertRulesToTableItems( - rules: Rule[], - ruleTypeIndex: RuleTypeIndex, - canExecuteActions: boolean -) { - return rules.map((rule, index: number) => ({ - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - })); +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts index aa0321ef8346b..fe9f921fc7f88 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts @@ -7,7 +7,12 @@ import { HttpSetup } from 'kibana/public'; import { BASE_TRIGGERS_ACTIONS_UI_API_PATH } from '../../../common'; +import { TriggersActionsUiConfig } from '../../types'; -export async function triggersActionsUiConfig({ http }: { http: HttpSetup }): Promise { +export async function triggersActionsUiConfig({ + http, +}: { + http: HttpSetup; +}): Promise { return await http.get(`${BASE_TRIGGERS_ACTIONS_UI_API_PATH}/_config`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0835ef2b7453e..7a1efaed33abf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -251,6 +251,7 @@ export interface RuleTableItem extends Rule { actionsCount: number; isEditable: boolean; enabledInLicense: boolean; + showIntervalWarning?: boolean; } export interface RuleTypeParamsExpressionProps< diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 0f6e99ccf27f3..14f169d778ebe 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -30,7 +30,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - describe('alerts list', function () { + describe('rules list', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -390,6 +390,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + it('should render interval info icon when schedule interval is less than configured minimum', async () => { + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'b', schedule: { interval: '1s' } }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'c' }, + }); + await refreshAlertsList(); + + await testSubjects.existOrFail('ruleInterval-config-icon-0'); + await testSubjects.missingOrFail('ruleInterval-config-icon-1'); + + // open edit flyout when icon is clicked + const infoIcon = await testSubjects.find('ruleInterval-config-icon-0'); + await infoIcon.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should delete all selection', async () => { const namePrefix = generateUniqueKey(); const createdAlert = await createAlertManualCleanup({ diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index b280e9a3e78c5..74595e812f42a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -91,6 +91,28 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } + async function createRuleWithSmallInterval( + testRunUuid: string, + params: Record = {} + ) { + const connectors = await createConnectors(testRunUuid); + return await createAlwaysFiringRule({ + name: `test-rule-${testRunUuid}`, + schedule: { + interval: '1s', + }, + actions: connectors.map((connector) => ({ + id: connector.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + params, + }); + } + async function getAlertSummary(ruleId: string) { const { body: summary } = await supertest .get(`/internal/alerting/rule/${encodeURIComponent(ruleId)}/_alert_summary`) @@ -116,7 +138,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testRunUuid = uuid.v4(); before(async () => { await pageObjects.common.navigateToApp('triggersActions'); - const rule = await createRuleWithActionsAndParams(testRunUuid); + const rule = await createRuleWithSmallInterval(testRunUuid); // refresh to see rule await browser.refresh(); @@ -145,6 +167,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(connectorType).to.be(`Slack`); }); + it('renders toast when schedule is less than configured minimum', async () => { + await testSubjects.existOrFail('intervalConfigToast'); + + const editButton = await testSubjects.find('ruleIntervalToastEditButton'); + await editButton.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should disable the rule', async () => { const enableSwitch = await testSubjects.find('enableSwitch'); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e906e239a8892..b2b6735a99c8b 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -67,7 +67,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ From f462be78a3e94979be528e70d19918eed2c1b35b Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:53:59 +0000 Subject: [PATCH 60/66] [APM] Make size required for ES search requests (#127970) * [APM] Make size required for ES search requests * fix tests * remove size field in unpack_processor_events.ts --- .../create_es_client/create_apm_event_client/index.test.ts | 1 + .../create_es_client/create_apm_event_client/index.ts | 3 +++ .../create_apm_event_client/unpack_processor_events.test.ts | 5 ++++- x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts | 6 ++++-- .../get_is_using_transaction_events.test.ts.snap | 4 ++++ .../helpers/transactions/get_is_using_transaction_events.ts | 1 + x-pack/plugins/apm/server/lib/helpers/transactions/index.ts | 1 + x-pack/plugins/apm/server/projections/typings.ts | 3 ++- .../get_overall_latency_distribution.ts | 4 ++-- .../latency_distribution/get_percentile_threshold_value.ts | 2 +- .../services/profiling/get_service_profiling_statistics.ts | 1 + .../custom_link/__snapshots__/get_transaction.test.ts.snap | 4 ++-- .../server/routes/settings/custom_link/get_transaction.ts | 2 +- 13 files changed, 27 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 68e29f7afcc79..d69740c51d04d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -61,6 +61,7 @@ describe('APMEventClient', () => { apm: { events: [], }, + body: { size: 0 }, }); return res.ok({ body: 'ok' }); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index fdf023e197b7c..4b8f63e33799c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -40,6 +40,9 @@ export type APMEventESSearchRequest = Omit & { events: ProcessorEvent[]; includeLegacyData?: boolean; }; + body: { + size: number; + }; }; export type APMEventESTermsEnumRequest = Omit & { diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts index d3f0fca0bb259..3b17c656b06e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts @@ -14,7 +14,10 @@ describe('unpackProcessorEvents', () => { beforeEach(() => { const request = { apm: { events: ['transaction', 'error'] }, - body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } }, + body: { + size: 0, + query: { bool: { filter: [{ terms: { foo: 'bar' } }] } }, + }, } as APMEventESSearchRequest; const indices = { diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 6d3789837d2d9..ae47abb01942e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -108,7 +108,7 @@ describe('setupRequest', () => { const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search('foo', { apm: { events: [ProcessorEvent.transaction] }, - body: { foo: 'bar' }, + body: { size: 10 }, }); expect( @@ -117,7 +117,7 @@ describe('setupRequest', () => { { index: ['apm-*'], body: { - foo: 'bar', + size: 10, query: { bool: { filter: [{ terms: { 'processor.event': ['transaction'] } }], @@ -172,6 +172,7 @@ describe('with includeFrozen=false', () => { apm: { events: [], }, + body: { size: 10 }, }); const params = @@ -193,6 +194,7 @@ describe('with includeFrozen=true', () => { await apmEventClient.search('foo', { apm: { events: [] }, + body: { size: 10 }, }); const params = diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap index 56d735b5df115..06e80110b6f20 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap @@ -31,6 +31,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -55,6 +56,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -82,6 +84,7 @@ Array [ ], }, }, + "size": 1, }, "terminate_after": 1, }, @@ -100,6 +103,7 @@ Array [ "filter": Array [], }, }, + "size": 0, }, "terminate_after": 1, }, diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts index 12c47936374e1..a28fe1ad1ecea 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts @@ -64,6 +64,7 @@ async function getHasTransactions({ events: [ProcessorEvent.transaction], }, body: { + size: 0, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts index 577a7544d93ea..573cb0a3cf6b4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts @@ -33,6 +33,7 @@ export async function getHasAggregatedTransactions({ events: [ProcessorEvent.metric], }, body: { + size: 1, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts index d252fd311b4fe..5558fba4cde2a 100644 --- a/x-pack/plugins/apm/server/projections/typings.ts +++ b/x-pack/plugins/apm/server/projections/typings.ts @@ -11,8 +11,9 @@ import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_ export type Projection = Omit & { body: Omit< Required['body'], - 'aggs' | 'aggregations' + 'aggs' | 'aggregations' | 'size' > & { + size?: number; aggs?: { [key: string]: { terms: AggregationOptionsByType['terms'] & { field: string }; diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts index 521a846c3e1df..d8e4cf7af0bc5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts @@ -64,7 +64,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: histogramIntervalRequestBody, + body: { size: 0, ...histogramIntervalRequestBody }, } )) as { aggregations?: { @@ -101,7 +101,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationRangesRequestBody, + body: { size: 0, ...transactionDurationRangesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts index 3961b1a2ca603..c40834919f7f5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts @@ -31,7 +31,7 @@ export async function getPercentileThresholdValue( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationPercentilesRequestBody, + body: { size: 0, ...transactionDurationPercentilesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts index 009d974e33721..3713b4faa73d9 100644 --- a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts +++ b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts @@ -141,6 +141,7 @@ function getProfilesWithStacks({ events: [ProcessorEvent.profile], }, body: { + size: 0, query: { bool: { filter, diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap index 921129cf2c1da..06011abc193c5 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap @@ -42,8 +42,8 @@ Object { ], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; @@ -61,8 +61,8 @@ Object { "filter": Array [], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts index 88d2ae9f339ac..d4e21f219f372 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts @@ -36,8 +36,8 @@ export async function getTransaction({ apm: { events: [ProcessorEvent.transaction as const], }, - size: 1, body: { + size: 1, query: { bool: { filter: esFilters, From a743498436a863e142592cb535b43f44c448851a Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Thu, 24 Mar 2022 09:59:05 -0400 Subject: [PATCH 61/66] [RAM] Add aggs to know how many rules are snoozed (#128212) * add aggs to know how many of snoozed rule exist * simplify + update o11y * fix tests * fix jest * bring back test --- x-pack/plugins/alerting/common/alert.ts | 1 + .../server/routes/aggregate_rules.test.ts | 9 ++++++ .../alerting/server/routes/aggregate_rules.ts | 2 ++ .../server/rules_client/rules_client.ts | 22 +++++++++++++++ .../rules_client/tests/aggregate.test.ts | 28 +++++++++++++++++++ .../containers/alerts_page/alerts_page.tsx | 13 ++++++--- .../spaces_only/tests/alerting/aggregate.ts | 17 +++++++---- 7 files changed, 83 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index dd85fadb49878..1628abff7efc1 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -76,6 +76,7 @@ export interface AlertAggregations { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; + ruleSnoozedStatus: { snoozed: number }; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 81fb66ef5cf55..038e923f28f0c 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -57,6 +57,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + ruleSnoozedStatus: { + snoozed: 4, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -88,6 +91,9 @@ describe('aggregateRulesRoute', () => { "muted": 2, "unmuted": 39, }, + "rule_snoozed_status": Object { + "snoozed": 4, + }, }, } `); @@ -120,6 +126,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + rule_snoozed_status: { + snoozed: 4, + }, }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index ee05897848ecf..8c44f57b83789 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -49,12 +49,14 @@ const rewriteBodyRes: RewriteResponseCase = ({ alertExecutionStatus, ruleEnabledStatus, ruleMutedStatus, + ruleSnoozedStatus, ...rest }) => ({ ...rest, rule_execution_status: alertExecutionStatus, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, + rule_snoozed_status: ruleSnoozedStatus, }); export const aggregateRulesRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 02901ca3fdc70..5f5baf41affae 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -125,6 +125,13 @@ export interface RuleAggregation { doc_count: number; }>; }; + snoozed: { + buckets: Array<{ + key: number; + key_as_string: string; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -191,6 +198,7 @@ export interface AggregateResult { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; + ruleSnoozedStatus?: { snoozed: number }; } export interface FindResult { @@ -859,6 +867,7 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter } = authorizationTuple; const resp = await this.unsecuredSavedObjectsClient.find({ ...options, @@ -879,6 +888,13 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }); @@ -894,6 +910,7 @@ export class RulesClient { muted: 0, unmuted: 0, }, + ruleSnoozedStatus: { snoozed: 0 }, }; for (const key of RuleExecutionStatusValues) { @@ -935,6 +952,11 @@ export class RulesClient { unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0, }; + const snoozedBuckets = resp.aggregations.snoozed.buckets; + ret.ruleSnoozedStatus = { + snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), + }; + return ret; } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index aa910f4203f46..af27decb73a2a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -101,6 +101,17 @@ describe('aggregate()', () => { { key: 1, key_as_string: '1', doc_count: 3 }, ], }, + snoozed: { + buckets: [ + { + key: '2022-03-21T20:22:01.501Z-*', + format: 'strict_date_time', + from: 1.647894121501e12, + from_as_string: '2022-03-21T20:22:01.501Z', + doc_count: 2, + }, + ], + }, }, }); @@ -146,6 +157,9 @@ describe('aggregate()', () => { "muted": 3, "unmuted": 27, }, + "ruleSnoozedStatus": Object { + "snoozed": 2, + }, } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -166,6 +180,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); @@ -193,6 +214,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index cf6ae92d1b9c8..939223feb87c0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -44,6 +44,7 @@ interface RuleStatsState { disabled: number; muted: number; error: number; + snoozed: number; } export interface TopAlert { @@ -90,6 +91,7 @@ function AlertsPage() { disabled: 0, muted: 0, error: 0, + snoozed: 0, }); useEffect(() => { @@ -111,18 +113,21 @@ function AlertsPage() { const response = await loadRuleAggregations({ http, }); - const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response; - if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) { + const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = + response; + if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) { const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0); const { disabled } = ruleEnabledStatus; const { muted } = ruleMutedStatus; const { error } = ruleExecutionStatus; + const { snoozed } = ruleSnoozedStatus; setRuleStats({ ...ruleStats, total, disabled, muted, error, + snoozed, }); } setRuleStatsLoading(false); @@ -263,9 +268,9 @@ function AlertsPage() { data-test-subj="statDisabled" />, Date: Thu, 24 Mar 2022 15:22:00 +0100 Subject: [PATCH 62/66] [IM] Remove `axios` dependency in tests (#128171) --- .../helpers/http_requests.ts | 249 ++++++++---------- .../helpers/setup_environment.tsx | 20 +- .../home/data_streams_tab.helpers.ts | 8 +- .../home/data_streams_tab.test.ts | 89 +++---- .../client_integration/home/home.helpers.ts | 9 +- .../client_integration/home/home.test.ts | 8 +- .../home/index_templates_tab.helpers.ts | 9 +- .../home/index_templates_tab.test.ts | 74 +++--- .../home/indices_tab.helpers.ts | 8 +- .../home/indices_tab.test.ts | 142 +++++++--- .../template_clone.helpers.ts | 12 +- .../template_clone.test.tsx | 34 +-- .../template_create.helpers.ts | 7 +- .../template_create.test.tsx | 81 +++--- .../template_edit.helpers.ts | 10 +- .../template_edit.test.tsx | 150 +++++------ .../template_form.helpers.ts | 2 +- .../component_template_create.test.tsx | 50 ++-- .../component_template_details.test.ts | 33 ++- .../component_template_edit.test.tsx | 37 +-- .../component_template_list.test.ts | 34 +-- .../component_template_create.helpers.ts | 9 +- .../component_template_details.helpers.ts | 5 +- .../component_template_edit.helpers.ts | 9 +- .../component_template_list.helpers.ts | 9 +- .../helpers/http_requests.ts | 118 +++++---- .../helpers/setup_environment.tsx | 22 +- 27 files changed, 638 insertions(+), 600 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 64b8b79d4b2a1..4726286319e52 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -5,10 +5,11 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from '../../../common/constants'; type HttpResponse = Record | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; export interface ResponseError { statusCode: number; @@ -17,139 +18,105 @@ export interface ResponseError { } // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadTemplatesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/indices`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setReloadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamsResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteTemplateResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setCreateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateIndexSettingsResponse = (response?: HttpResponse, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ?? response; - - server.respondWith('PUT', `${API_BASE_PATH}/settings/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); + + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); + }; + + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); + + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); + }; + + const setLoadTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/index_templates`, response, error); + + const setLoadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/indices`, response, error); + + const setReloadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/indices/reload`, response, error); + + const setLoadDataStreamsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/data_streams`, response, error); + + const setLoadDataStreamResponse = ( + dataStreamId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'GET', + `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}`, + response, + error + ); + + const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error); + + const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error); + + const setLoadTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setCreateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates`, response, error); + + const setUpdateTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setUpdateIndexSettingsResponse = ( + indexName: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/settings/${indexName}`, response, error); + + const setSimulateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates/simulate`, response, error); + + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); + + const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/nodes/plugins`, response, error); + + const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', '/api/ui_counters/_report', response, error); return { setLoadTemplatesResponse, @@ -166,22 +133,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSimulateTemplateResponse, setLoadComponentTemplatesResponse, setLoadNodesPluginsResponse, + setLoadTelemetryResponse, }; }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultSinonMockServerResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 1682431900a84..c5b077ef00333 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,11 +6,10 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { merge } from 'lodash'; import SemVer from 'semver/classes/semver'; +import { HttpSetup } from 'src/core/public'; import { notificationServiceMock, docLinksServiceMock, @@ -36,7 +35,6 @@ import { import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__'; import { init as initHttpRequests } from './http_requests'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; export const services = { @@ -64,30 +62,24 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ }); export const setupEnvironment = () => { - // Mock initialization of services - // @ts-ignore - httpService.setup(mockHttpClient); breadcrumbService.setup(() => undefined); documentationService.setup(docLinksServiceMock.createStartContract()); notificationService.setup(notificationServiceMock.createSetupContract()); - const { server, httpRequestsMockHelpers } = initHttpRequests(); - - return { - server, - httpRequestsMockHelpers, - }; + return initHttpRequests(); }; export const WithAppDependencies = - (Comp: any, overridingDependencies: any = {}) => + (Comp: any, httpSetup: HttpSetup, overridingDependencies: any = {}) => (props: any) => { + httpService.setup(httpSetup); const mergedDependencies = merge({}, appDependencies, overridingDependencies); + return ( - + diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index e3295a8f4fb18..9eeab1d3ca78b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -15,6 +15,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { DataStream } from '../../../common'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; @@ -46,7 +47,10 @@ export interface DataStreamsTabTestBed extends TestBed { findDetailPanelIndexTemplateLink: () => ReactWrapper; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const testBedConfig: AsyncTestBedConfig = { store: () => indexManagementStore(services as any), memoryRouter: { @@ -57,7 +61,7 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no data streams', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); @@ -53,7 +49,7 @@ describe('Data Streams tab', () => { }); test('displays an empty prompt', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { url: urlServiceMock, }); @@ -69,7 +65,7 @@ describe('Data Streams tab', () => { }); test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -89,7 +85,7 @@ describe('Data Streams tab', () => { }); test('when Fleet is enabled, links to Fleet', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: { isFleetEnabled: true }, url: urlServiceMock, }); @@ -112,7 +108,7 @@ describe('Data Streams tab', () => { }); httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -156,13 +152,13 @@ describe('Data Streams tab', () => { }), ]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' }); setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); - setLoadTemplateResponse(indexTemplate); + setLoadTemplateResponse(indexTemplate.name, indexTemplate); - testBed = await setup({ history: createMemoryHistory() }); + testBed = await setup(httpSetup, { history: createMemoryHistory() }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -181,7 +177,6 @@ describe('Data Streams tab', () => { test('has a button to reload the data streams', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -189,13 +184,14 @@ describe('Data Streams tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); }); test('has a switch that will reload the data streams with additional stats when clicked', async () => { const { exists, actions, table, component } = testBed; - const totalRequests = server.requests.length; expect(exists('includeStatsSwitch')).toBe(true); @@ -205,9 +201,10 @@ describe('Data Streams tab', () => { }); component.update(); - // A request is sent, but sinon isn't capturing the query parameters for some reason. - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); // The table renders with the stats columns though. const { tableCellsValues } = table.getMetaData('dataStreamTable'); @@ -279,19 +276,17 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); }); describe('detail panel', () => { test('opens when the data stream name in the table is clicked', async () => { const { actions, findDetailPanel, findDetailPanelTitle } = testBed; + httpRequestsMockHelpers.setLoadDataStreamResponse('dataStream1'); await actions.clickNameAt(0); expect(findDetailPanel().length).toBe(1); expect(findDetailPanelTitle()).toBe('dataStream1'); @@ -315,13 +310,10 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); test('clicking index template name navigates to the index template details', async () => { @@ -358,9 +350,9 @@ describe('Data Streams tab', () => { const dataStreamPercentSign = createDataStreamPayload({ name: '%dataStream' }); setLoadDataStreamsResponse([dataStreamPercentSign]); - setLoadDataStreamResponse(dataStreamPercentSign); + setLoadDataStreamResponse(dataStreamPercentSign.name, dataStreamPercentSign); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -396,10 +388,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -417,10 +410,11 @@ describe('Data Streams tab', () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -442,10 +436,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: { locators: { @@ -476,9 +471,10 @@ describe('Data Streams tab', () => { }, }); const nonManagedDataStream = createDataStreamPayload({ name: 'non-managed-data-stream' }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([managedDataStream, nonManagedDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -520,9 +516,10 @@ describe('Data Streams tab', () => { name: 'hidden-data-stream', hidden: true, }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -561,7 +558,7 @@ describe('Data Streams tab', () => { beforeEach(async () => { setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); - testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock }); + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -599,7 +596,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamWithDelete); + setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete); await clickNameAt(1); expect(find('deleteDataStreamButton').exists()).toBeTruthy(); @@ -610,7 +607,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamNoDelete); + setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete); await clickNameAt(0); expect(find('deleteDataStreamButton').exists()).toBeFalsy(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts index 46287fcdcf074..b73985dc8372b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -19,8 +20,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); - export interface HomeTestBed extends TestBed { actions: { selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void; @@ -28,7 +27,11 @@ export interface HomeTestBed extends TestBed { }; } -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(IndexManagementHome, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); const { find } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts index 60d4b7d3f2317..c3f8a5b17068d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts @@ -20,18 +20,14 @@ import { stubWebWorker } from '@kbn/test-jest-helpers'; stubWebWorker(); describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: HomeTestBed; - afterAll(() => { - server.restore(); - }); - describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index 69dcabc287d6b..a16ba0768e675 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -13,6 +13,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateList } from '../../../public/application/sections/home/template_list'; import { TemplateDeserialized } from '../../../common'; import { WithAppDependencies, TestSubjects } from '../helpers'; @@ -25,8 +26,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig); - const createActions = (testBed: TestBed) => { /** * Additional helpers @@ -132,7 +131,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index bf1a78e3cfe90..3d1360d620ff5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -24,19 +24,15 @@ const removeWhiteSpaceOnArrayValues = (array: any[]) => }); describe('Index Templates tab', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: IndexTemplatesTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no index templates of either kind', () => { test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -54,7 +50,7 @@ describe('Index Templates tab', () => { }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -68,7 +64,8 @@ describe('Index Templates tab', () => { describe('when there are index templates', () => { // Add a default loadIndexTemplate response - httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate()); + const templateMock = fixtures.getTemplate(); + httpRequestsMockHelpers.setLoadTemplateResponse(templateMock.name, templateMock); const template1 = fixtures.getTemplate({ name: `a${getRandomString()}`, @@ -132,7 +129,7 @@ describe('Index Templates tab', () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -194,7 +191,6 @@ describe('Index Templates tab', () => { test('should have a button to reload the index templates', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -202,9 +198,9 @@ describe('Index Templates tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/index_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.anything() ); }); @@ -235,6 +231,7 @@ describe('Index Templates tab', () => { const { find, exists, actions, component } = testBed; // Composable templates + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateList')).toBe(true); expect(exists('templateDetails')).toBe(true); @@ -246,6 +243,7 @@ describe('Index Templates tab', () => { }); component.update(); + httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplates[0].name, legacyTemplates[0]); await actions.clickTemplateAt(0, true); expect(exists('templateList')).toBe(true); @@ -380,13 +378,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - templates: [{ name: templates[0].name, isLegacy }], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy }], + }), + }) + ); }); }); @@ -442,16 +441,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - - // Commenting as I don't find a way to make it work. - // It keeps on returning the composable template instead of the legacy one - // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - // templates: [{ name: templateName, isLegacy }], - // }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy: false }], + }), + }) + ); }); }); @@ -463,7 +460,7 @@ describe('Index Templates tab', () => { isLegacy: true, }); - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(template.name, template); }); test('should show details when clicking on a template', async () => { @@ -471,6 +468,7 @@ describe('Index Templates tab', () => { expect(exists('templateDetails')).toBe(false); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateDetails')).toBe(true); @@ -480,6 +478,7 @@ describe('Index Templates tab', () => { beforeEach(async () => { const { actions } = testBed; + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); }); @@ -544,7 +543,7 @@ describe('Index Templates tab', () => { const { find, actions, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, template); httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' }); await actions.clickTemplateAt(0); @@ -598,8 +597,10 @@ describe('Index Templates tab', () => { const { actions, find, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoOptionalFields); - + httpRequestsMockHelpers.setLoadTemplateResponse( + templates[0].name, + templateWithNoOptionalFields + ); await actions.clickTemplateAt(0); expect(find('templateDetails.tab').length).toBe(5); @@ -621,13 +622,12 @@ describe('Index Templates tab', () => { it('should render an error message if error fetching template details', async () => { const { actions, exists } = testBed; const error = { - status: 404, + statusCode: 404, error: 'Not found', message: 'Template not found', }; - httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error }); - + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, undefined, error); await actions.clickTemplateAt(0); expect(exists('sectionError')).toBe(true); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 7daa3cc9e2221..5feb7840f259c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -14,6 +14,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -42,9 +43,12 @@ export interface IndicesTestBed extends TestBed { findDataStreamDetailPanelTitle: () => string; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(IndexManagementHome, overridingDependencies), + WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index 8193d48629f6f..541f2b587b69f 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -49,22 +49,20 @@ stubWebWorker(); describe('', () => { let testBed: IndicesTestBed; - let server: ReturnType['server']; + let httpSetup: ReturnType['httpSetup']; let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; beforeEach(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; @@ -118,10 +116,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadDataStreamsResponse([]); httpRequestsMockHelpers.setLoadDataStreamResponse( + 'dataStream1', createDataStreamPayload({ name: 'dataStream1' }) ); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), }); @@ -162,7 +161,7 @@ describe('', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -174,32 +173,36 @@ describe('', () => { const { actions } = testBed; await actions.selectIndexDetailsTab('settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading mappings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('mappings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading stats in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('stats'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when editing settings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('edit_settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); }); @@ -222,7 +225,7 @@ describe('', () => { ]); httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexNameA, indexNameB] }); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -236,8 +239,14 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('refreshIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/refresh`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/refresh`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to close an open index', async () => { @@ -246,13 +255,20 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('closeIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`); + // After the index is closed, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/close`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to open a closed index', async () => { - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find, actions } = testBed; component.update(); @@ -262,9 +278,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('openIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/open`); + // After the index is opened, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/open`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to flush index', async () => { @@ -273,11 +296,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('flushIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`); - // After the indices are flushed, we imediately reload them. So we need to expect to see + // After the index is flushed, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/flush`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test("should be able to clear an index's cache", async () => { @@ -287,8 +315,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('clearCacheIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/clear_cache`); + // After the index cache is cleared, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/clear_cache`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to unfreeze a frozen index', async () => { @@ -302,11 +338,17 @@ describe('', () => { expect(exists('unfreezeIndexMenuButton')).toBe(true); await actions.clickContextMenuOption('unfreezeIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/unfreeze`); // After the index is unfrozen, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/unfreeze`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); + // Open context menu once again, since clicking an action will close it. await actions.clickManageContextMenuButton(); // The unfreeze action should not be present anymore @@ -326,15 +368,33 @@ describe('', () => { await actions.clickModalConfirm(); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/forcemerge`); - // After the index is force merged, we immediately do a reload. So we need to expect to see + // After the index force merged, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/forcemerge`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); }); describe('Edit index settings', () => { + const indexName = 'test'; + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); + + testBed = await setup(httpSetup); + const { component, find } = testBed; + + component.update(); + + find('indexTableIndexNameLink').at(0).simulate('click'); + }); + test('shows error callout when request fails', async () => { const { actions, find, component, exists } = testBed; @@ -347,7 +407,7 @@ describe('', () => { error: 'Bad Request', message: 'invalid tier names found in ...', }; - httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error); + httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexName, undefined, error); await actions.selectIndexDetailsTab('edit_settings'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts index 9aec6cae7a17e..2ee82c2b4c418 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts @@ -6,10 +6,11 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateClone } from '../../../public/application/sections/template_clone'; import { WithAppDependencies } from '../helpers'; -import { formSetup } from './template_form.helpers'; +import { formSetup, TestSubjects } from './template_form.helpers'; import { TEMPLATE_NAME } from './constants'; const testBedConfig: AsyncTestBedConfig = { @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateClone), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateClone, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 31e65625cfdd0..861b1041a4f14 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { getComposableTemplate } from '../../../test/fixtures'; import { setupEnvironment } from '../helpers'; @@ -44,23 +45,22 @@ const templateToClone = getComposableTemplate({ describe('', () => { let testBed: TemplateFormTestBed; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); + httpRequestsMockHelpers.setLoadTelemetryResponse({}); httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); - httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone); + httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone); }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -98,17 +98,19 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - ...templateToClone, - name: `${templateToClone.name}-copy`, - indexPatterns: DEFAULT_INDEX_PATTERNS, - }; - - delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + const { priority, version, _kbnMeta } = templateToClone; + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: `${templateToClone.name}-copy`, + indexPatterns: DEFAULT_INDEX_PATTERNS, + priority, + version, + _kbnMeta, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts index b039fa83000ed..e57e89a6762c2 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts @@ -6,12 +6,13 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateCreate } from '../../../public/application/sections/template_create'; import { WithAppDependencies } from '../helpers'; import { formSetup, TestSubjects } from './template_form.helpers'; -export const setup: any = (isLegacy: boolean = false) => { +export const setup = async (httpSetup: HttpSetup, isLegacy: boolean = false) => { const route = isLegacy ? { pathname: '/create_template', search: '?legacy=true' } : { pathname: '/create_template' }; @@ -25,9 +26,9 @@ export const setup: any = (isLegacy: boolean = false) => { }; const initTestBed = registerTestBed( - WithAppDependencies(TemplateCreate), + WithAppDependencies(TemplateCreate, httpSetup), testBedConfig ); - return formSetup.call(null, initTestBed); + return formSetup(initTestBed); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 65d3678735689..078a171ac6a75 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment } from '../helpers'; import { @@ -76,7 +77,7 @@ const componentTemplates = [componentTemplate1, componentTemplate2]; describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -89,7 +90,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); (window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false; }); @@ -97,7 +97,7 @@ describe('', () => { describe('composable index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); }); @@ -130,7 +130,7 @@ describe('', () => { describe('legacy index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(true); + testBed = await setup(httpSetup, true); }); }); @@ -150,7 +150,7 @@ describe('', () => { describe('form validation', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -367,7 +367,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); await navigateToMappingsStep(); @@ -415,7 +415,7 @@ describe('', () => { describe('review (step 6)', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -472,7 +472,7 @@ describe('', () => { it('should render a warning message if a wildcard is used as an index pattern', async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -505,7 +505,7 @@ describe('', () => { const MAPPING_FIELDS = [BOOLEAN_MAPPING_FIELD, TEXT_MAPPING_FIELD, KEYWORD_MAPPING_FIELD]; await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -534,49 +534,50 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: TEMPLATE_NAME, - indexPatterns: DEFAULT_INDEX_PATTERNS, - composedOf: ['test_component_template_1'], - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, - }, - [TEXT_MAPPING_FIELD.name]: { - type: TEXT_MAPPING_FIELD.type, - }, - [KEYWORD_MAPPING_FIELD.name]: { - type: KEYWORD_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: DEFAULT_INDEX_PATTERNS, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + composedOf: ['test_component_template_1'], + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + [TEXT_MAPPING_FIELD.name]: { + type: TEXT_MAPPING_FIELD.type, + }, + [KEYWORD_MAPPING_FIELD.name]: { + type: KEYWORD_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); it('should surface the API errors from the put HTTP request', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts index a7f87d828eb23..97166970568d3 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateEdit } from '../../../public/application/sections/template_edit'; import { WithAppDependencies } from '../helpers'; @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateEdit), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateEdit, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index d4680e7663322..4b94cb92c83d0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; import * as fixtures from '../../../test/fixtures'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment, kibanaVersion } from '../helpers'; import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants'; @@ -48,7 +49,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -56,7 +57,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); @@ -71,12 +71,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -117,24 +117,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: 'test', - indexPatterns: ['myPattern*'], - dataStream: { - hidden: true, - anyUnknownKey: 'should_be_kept', - }, - version: 1, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: true, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/test`, + expect.objectContaining({ + body: JSON.stringify({ + name: 'test', + indexPatterns: ['myPattern*'], + version: 1, + dataStream: { + hidden: true, + anyUnknownKey: 'should_be_kept', + }, + _kbnMeta: { + type: 'default', + hasDatastream: true, + isLegacy: false, + }, + }), + }) + ); }); }); @@ -148,12 +149,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -225,40 +226,40 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version } = templateToEdit; - - const expected = { - name: TEMPLATE_NAME, - version, - priority: 3, - indexPatterns: UPDATED_INDEX_PATTERN, - template: { - mappings: { - properties: { - [UPDATED_MAPPING_TEXT_FIELD_NAME]: { - type: 'text', - store: false, - index: true, - fielddata: false, - eager_global_ordinals: false, - index_phrases: false, - norms: true, - index_options: 'positions', + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: UPDATED_INDEX_PATTERN, + priority: 3, + version: templateToEdit.version, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: templateToEdit._kbnMeta.isLegacy, + }, + template: { + settings: SETTINGS, + mappings: { + properties: { + [UPDATED_MAPPING_TEXT_FIELD_NAME]: { + type: 'text', + index: true, + eager_global_ordinals: false, + index_phrases: false, + norms: true, + fielddata: false, + store: false, + index_options: 'positions', + }, + }, }, + aliases: ALIASES, }, - }, - settings: SETTINGS, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: templateToEdit._kbnMeta.isLegacy, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); }); }); @@ -277,12 +278,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -305,24 +306,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit; - const expected = { - name, - indexPatterns, - version, - order, - template: { - aliases: undefined, - mappings: template!.mappings, - settings: undefined, - }, - _kbnMeta, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name, + indexPatterns, + version, + order, + template: { + aliases: undefined, + mappings: template!.mappings, + settings: undefined, + }, + _kbnMeta, + }), + }) + ); }); }); } diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 57d0b282d351d..9a68fe41fce27 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed, SetupFunc } from '@kbn/test-jest-helpers'; import { TemplateDeserialized } from '../../../common'; -interface MappingField { +export interface MappingField { name: string; type: string; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index f3957e0cc15c9..81f43a1b46073 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; jest.mock('@elastic/eui', () => { @@ -34,16 +35,12 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateCreateTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); describe('On component mount', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -108,7 +105,7 @@ describe('', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { actions, component } = testBed; @@ -164,37 +161,38 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: COMPONENT_TEMPLATE_NAME, - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: COMPONENT_TEMPLATE_NAME, + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { usedBy: [], isManaged: false }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + _kbnMeta: { usedBy: [], isManaged: false }, + }), + }) + ); }); test('should surface API errors if the request is unsuccessful', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 36ea2c27ec4fe..95495af1272c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -32,19 +32,18 @@ const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = { }; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateDetailsTestBed; - afterAll(() => { - server.restore(); - }); - describe('With component template details', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); @@ -104,11 +103,12 @@ describe('', () => { describe('With only required component template fields', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, onClose: () => {}, }); @@ -156,10 +156,13 @@ describe('', () => { describe('With actions', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, actions: [ @@ -197,16 +200,20 @@ describe('', () => { describe('Error handling', () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + undefined, + error + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 1f4abac806276..f3b5b52fe2c41 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; jest.mock('@elastic/eui', () => { @@ -33,11 +34,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateEditTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); const COMPONENT_TEMPLATE_NAME = 'comp-1'; const COMPONENT_TEMPLATE_TO_EDIT = { @@ -49,10 +46,13 @@ describe('', () => { }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_TO_EDIT.name, + COMPONENT_TEMPLATE_TO_EDIT + ); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -98,17 +98,18 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - version: 1, - ...COMPONENT_TEMPLATE_TO_EDIT, - template: { - ...COMPONENT_TEMPLATE_TO_EDIT.template, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${COMPONENT_TEMPLATE_TO_EDIT.name}`, + expect.objectContaining({ + body: JSON.stringify({ + ...COMPONENT_TEMPLATE_TO_EDIT, + template: { + ...COMPONENT_TEMPLATE_TO_EDIT.template, + }, + version: 1, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index dee15f2ae3a45..a3e9524dcd3ca 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -16,16 +16,12 @@ import { API_BASE_PATH } from './helpers/constants'; const { setup } = pageHelpers.componentTemplateList; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateListTestBed; - afterAll(() => { - server.restore(); - }); - beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -69,7 +65,6 @@ describe('', () => { test('should reload the component templates data', async () => { const { component, actions } = testBed; - const totalRequests = server.requests.length; await act(async () => { actions.clickReloadButton(); @@ -77,9 +72,9 @@ describe('', () => { component.update(); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/component_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.anything() ); }); @@ -103,7 +98,7 @@ describe('', () => { expect(modal).not.toBe(null); expect(modal!.textContent).toContain('Delete component template'); - httpRequestsMockHelpers.setDeleteComponentTemplateResponse({ + httpRequestsMockHelpers.setDeleteComponentTemplateResponse(componentTemplateName, { itemsDeleted: [componentTemplateName], errors: [], }); @@ -114,13 +109,10 @@ describe('', () => { component.update(); - const deleteRequest = server.requests[server.requests.length - 2]; - - expect(deleteRequest.method).toBe('DELETE'); - expect(deleteRequest.url).toBe( - `${API_BASE_PATH}/component_templates/${componentTemplateName}` + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${componentTemplateName}`, + expect.anything() ); - expect(deleteRequest.status).toEqual(200); }); }); @@ -129,7 +121,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -147,15 +139,15 @@ describe('', () => { describe('Error handling', () => { beforeEach(async () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; - httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, error); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts index 18b5bbfd775bb..846c921e776c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateCreate } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateCreate, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts index cdf376028ff1d..18fe2b59f21c6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { WithAppDependencies } from './setup_environment'; import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details'; @@ -43,9 +44,9 @@ const createActions = (testBed: TestBed) = }; }; -export const setup = (props: any): ComponentTemplateDetailsTestBed => { +export const setup = (httpSetup: HttpSetup, props: any): ComponentTemplateDetailsTestBed => { const setupTestBed = registerTestBed( - WithAppDependencies(ComponentTemplateDetailsFlyoutContent), + WithAppDependencies(ComponentTemplateDetailsFlyoutContent, httpSetup), { memoryRouter: { wrapComponent: false, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts index 6e0f9d55ef7f0..dfc73e0ccafb0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateEdit } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateEdit, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts index 2a01518e25466..3005eae0d6bf1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts @@ -6,6 +6,7 @@ */ import { act } from 'react-dom/test-utils'; +import { HttpSetup } from 'src/core/public'; import { registerTestBed, @@ -26,8 +27,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig); - export type ComponentTemplateListTestBed = TestBed & { actions: ReturnType; }; @@ -74,7 +73,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts index 520da90c58862..025f34066908c 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts @@ -5,65 +5,74 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; -import { - ComponentTemplateListItem, - ComponentTemplateDeserialized, - ComponentTemplateSerialized, -} from '../../../shared_imports'; +import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from './constants'; +type HttpResponse = Record | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; + +export interface ResponseError { + statusCode: number; + message: string | Error; + attributes?: Record; +} + // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadComponentTemplatesResponse = ( - response?: ComponentTemplateListItem[], - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); }; - const setLoadComponentTemplateResponse = ( - response?: ComponentTemplateDeserialized, - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; - const setDeleteComponentTemplateResponse = (response?: object) => { - server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); }; - const setCreateComponentTemplateResponse = ( - response?: ComponentTemplateSerialized, - error?: any - ) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); - server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; + const setLoadComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setDeleteComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse('DELETE', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setCreateComponentTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/component_templates`, response, error); return { setLoadComponentTemplatesResponse, @@ -74,18 +83,11 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultMockedResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index d532eaaba8923..9c2017ad651f1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,8 +6,6 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { HttpSetup } from 'kibana/public'; import { @@ -24,7 +22,6 @@ import { ComponentTemplatesProvider } from '../../../component_templates_context import { init as initHttpRequests } from './http_requests'; import { API_BASE_PATH } from './constants'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; // We provide the minimum deps required to make the tests pass @@ -32,30 +29,23 @@ const appDependencies = { docLinks: {} as any, } as any; -export const componentTemplatesDependencies = { - httpClient: mockHttpClient as unknown as HttpSetup, +export const componentTemplatesDependencies = (httpSetup: HttpSetup) => ({ + httpClient: httpSetup, apiBasePath: API_BASE_PATH, trackMetric: () => {}, docLinks: docLinksServiceMock.createStartContract(), toasts: notificationServiceMock.createSetupContract().toasts, setBreadcrumbs: () => {}, getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, -}; +}); -export const setupEnvironment = () => { - const { server, httpRequestsMockHelpers } = initHttpRequests(); +export const setupEnvironment = initHttpRequests; - return { - server, - httpRequestsMockHelpers, - }; -}; - -export const WithAppDependencies = (Comp: any) => (props: any) => +export const WithAppDependencies = (Comp: any, httpSetup: HttpSetup) => (props: any) => ( - + From 2e861694ef3effdfaefd2e81bd4d2659c6d09a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Thu, 24 Mar 2022 15:28:52 +0100 Subject: [PATCH 63/66] [Unified observability] Guided setup progress (#128382) * wip - add observability status progress bar * added option to dismiss guided setup * Remove extra panel * open flyout on view details button click * add some tests for status progress * fix type * Add telemetry * Not show titles when there are no boxes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability_status_boxes.tsx | 70 ++++++----- .../observability_status_progress.test.tsx | 63 ++++++++++ .../observability_status_progress.tsx | 118 ++++++++++++++++++ .../pages/overview/old_overview_page.tsx | 2 + 4 files changed, 222 insertions(+), 31 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx create mode 100644 x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index 48779569131d6..2fdf0a07f4647 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -27,39 +27,47 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { return ( - - -

    - -

    -
    -
    - {noHasDataBoxes.map((box) => ( - - - - ))} + {noHasDataBoxes.length > 0 && ( + <> + + +

    + +

    +
    +
    + {noHasDataBoxes.map((box) => ( + + + + ))} + + )} - {noHasDataBoxes.length > 0 && } + {noHasDataBoxes.length > 0 && hasDataBoxes.length > 0 && } - - -

    - -

    -
    -
    - {hasDataBoxes.map((box) => ( - - - - ))} + {hasDataBoxes.length > 0 && ( + <> + + +

    + +

    +
    +
    + {hasDataBoxes.map((box) => ( + + + + ))} + + )}
    ); } diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx new file mode 100644 index 0000000000000..6e79c3691402a --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HasDataContextValue } from '../../../context/has_data_context'; +import * as hasDataHook from '../../../hooks/use_has_data'; +import { ObservabilityStatusProgress } from './observability_status_progress'; +import { I18nProvider } from '@kbn/i18n-react'; + +describe('ObservabilityStatusProgress', () => { + const onViewDetailsClickFn = jest.fn(); + + beforeEach(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasDataMap: { + apm: { hasData: true, status: 'success' }, + synthetics: { hasData: true, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: false, status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + onRefreshTimeRange: () => {}, + forceUpdate: '', + } as HasDataContextValue); + }); + it('should render the progress', () => { + render( + + + + ); + const progressBar = screen.getByRole('progressbar') as HTMLProgressElement; + expect(progressBar).toBeInTheDocument(); + expect(progressBar.value).toBe(50); + }); + + it('should call the onViewDetailsCallback when view details button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('View details')); + expect(onViewDetailsClickFn).toHaveBeenCalled(); + }); + + it('should hide the component when dismiss button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('Dismiss')); + expect(screen.queryByTestId('status-progress')).toBe(null); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx new file mode 100644 index 0000000000000..81f08537c775f --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect } from 'react'; +import { + EuiPanel, + EuiProgress, + EuiTitle, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { reduce } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useHasData } from '../../../hooks/use_has_data'; +import { useUiTracker } from '../../../hooks/use_track_metric'; + +const LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY = 'HIDE_GUIDED_SETUP'; + +interface ObservabilityStatusProgressProps { + onViewDetailsClick: () => void; +} +export function ObservabilityStatusProgress({ + onViewDetailsClick, +}: ObservabilityStatusProgressProps) { + const { hasDataMap, isAllRequestsComplete } = useHasData(); + const trackMetric = useUiTracker({ app: 'observability-overview' }); + const hideGuidedSetupLocalStorageKey = window.localStorage.getItem( + LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY + ); + const [isGuidedSetupHidden, setIsGuidedSetupHidden] = useState( + JSON.parse(hideGuidedSetupLocalStorageKey || 'false') + ); + const [progress, setProgress] = useState(0); + + useEffect(() => { + const totalCounts = Object.keys(hasDataMap); + if (isAllRequestsComplete) { + const hasDataCount = reduce( + hasDataMap, + (result, value) => { + return value?.hasData ? result + 1 : result; + }, + 0 + ); + + const percentage = (hasDataCount / totalCounts.length) * 100; + setProgress(isFinite(percentage) ? percentage : 0); + } + }, [isAllRequestsComplete, hasDataMap]); + + const hideGuidedSetup = () => { + window.localStorage.setItem(LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY, 'true'); + setIsGuidedSetupHidden(true); + trackMetric({ metric: 'guided_setup_progress_dismiss' }); + }; + + const showDetails = () => { + onViewDetailsClick(); + trackMetric({ metric: 'guided_setup_progress_view_details' }); + }; + + return !isGuidedSetupHidden ? ( + <> + + + + + + +

    + +

    +
    + +

    + +

    +
    +
    + + + + + + + + + + + + + + +
    +
    + + + ) : null; +} diff --git a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx index 88c82d8c355ac..65f9def2d0f4a 100644 --- a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx @@ -45,6 +45,7 @@ import { ObservabilityAppServices } from '../../application/types'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { paths } from '../../config'; import { useDatePickerContext } from '../../hooks/use_date_picker_context'; +import { ObservabilityStatusProgress } from '../../components/app/observability_status/observability_status_progress'; import { ObservabilityStatus } from '../../components/app/observability_status'; interface Props { routeParams: RouteParams<'/overview'>; @@ -145,6 +146,7 @@ export function OverviewPage({ routeParams }: Props) { {hasData && ( <> + setIsFlyoutVisible(true)} /> Date: Thu, 24 Mar 2022 16:31:08 +0200 Subject: [PATCH 64/66] [Cloud Posture] Support csp rule asset type (#127741) Co-authored-by: Uri Weisman uri.weisman@elastic.co --- .../common/schemas/csp_rule_template.ts | 23 +++ .../cloud_security_posture/server/config.ts | 1 - .../cloud_security_posture/server/index.ts | 1 + .../cloud_security_posture/server/plugin.ts | 6 +- .../server/saved_objects/csp_rule_template.ts | 29 ++++ .../{cis_1_4_1 => }/csp_rule_type.ts | 7 +- .../{cis_1_4_1 => }/initialize_rules.ts | 4 +- .../context/fixtures/integration.nginx.ts | 1 + .../context/fixtures/integration.okta.ts | 1 + .../plugins/fleet/common/openapi/bundled.json | 3 +- .../plugins/fleet/common/openapi/bundled.yaml | 1 + .../schemas/kibana_saved_object_type.yaml | 1 + .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../components/assets_facet_group.stories.tsx | 1 + .../integrations/sections/epm/constants.tsx | 7 + .../services/epm/kibana/assets/install.ts | 2 + ...kage_policies_to_agent_permissions.test.ts | 4 + .../apis/epm/install_remove_assets.ts | 144 ++++++++++++++---- .../apis/epm/update_assets.ts | 5 + .../sample_csp_rule_template.json | 17 +++ .../sample_csp_rule_template.json | 17 +++ x-pack/test/fleet_api_integration/config.ts | 1 + 23 files changed, 240 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts create mode 100644 x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts rename x-pack/plugins/cloud_security_posture/server/saved_objects/{cis_1_4_1 => }/csp_rule_type.ts (90%) rename x-pack/plugins/cloud_security_posture/server/saved_objects/{cis_1_4_1 => }/initialize_rules.ts (83%) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts new file mode 100644 index 0000000000000..e6c7740f87fd3 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema as rt, TypeOf } from '@kbn/config-schema'; + +const cspRuleTemplateSchema = rt.object({ + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + severity: rt.string(), + benchmark_rule_id: rt.string(), + rego_rule_id: rt.string(), + tags: rt.arrayOf(rt.string()), +}); +export const cloudSecurityPostureRuleTemplateSavedObjectType = 'csp-rule-template'; +export type CloudSecurityPostureRuleTemplateSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/server/config.ts b/x-pack/plugins/cloud_security_posture/server/config.ts index 9c9ff926a2c38..e40adadc55e98 100644 --- a/x-pack/plugins/cloud_security_posture/server/config.ts +++ b/x-pack/plugins/cloud_security_posture/server/config.ts @@ -11,7 +11,6 @@ import type { PluginConfigDescriptor } from 'kibana/server'; const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), }); - type CloudSecurityPostureConfig = TypeOf; export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index f790ac5256ff8..82f2872a859f7 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; export type { CspServerPluginSetup, CspServerPluginStart } from './types'; +export type { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; export const plugin = (initializerContext: PluginInitializerContext) => new CspPlugin(initializerContext); diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index 2709518ffbc5f..386eb2373ad63 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -21,8 +21,9 @@ import type { CspRequestHandlerContext, } from './types'; import { defineRoutes } from './routes'; -import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; -import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; +import { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; +import { cspRuleAssetType } from './saved_objects/csp_rule_type'; +import { initializeCspRules } from './saved_objects/initialize_rules'; import { initializeCspTransformsIndices } from './create_indices/create_transforms_indices'; export interface CspAppContext { @@ -55,6 +56,7 @@ export class CspPlugin }; core.savedObjects.registerType(cspRuleAssetType); + core.savedObjects.registerType(cspRuleTemplateAssetType); const router = core.http.createRouter(); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts new file mode 100644 index 0000000000000..e1082cc59db3f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from '../../../../../src/core/server'; +import { + type CloudSecurityPostureRuleTemplateSchema, + cloudSecurityPostureRuleTemplateSavedObjectType, +} from '../../common/schemas/csp_rule_template'; + +const ruleTemplateAssetSavedObjectMappings: SavedObjectsType['mappings'] = + { + dynamic: false, + properties: {}, + }; + +export const cspRuleTemplateAssetType: SavedObjectsType = { + name: cloudSecurityPostureRuleTemplateSavedObjectType, + hidden: false, + management: { + importableAndExportable: true, + visibleInManagement: true, + }, + namespaceType: 'agnostic', + mappings: ruleTemplateAssetSavedObjectMappings, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts similarity index 90% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts index fcff7449fb3f5..4b323c127c0e6 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts @@ -6,15 +6,12 @@ */ import { i18n } from '@kbn/i18n'; -import type { - SavedObjectsType, - SavedObjectsValidationMap, -} from '../../../../../../src/core/server'; +import type { SavedObjectsType, SavedObjectsValidationMap } from '../../../../../src/core/server'; import { type CspRuleSchema, cspRuleSchema, cspRuleAssetSavedObjectType, -} from '../../../common/schemas/csp_rule'; +} from '../../common/schemas/csp_rule'; const validationMap: SavedObjectsValidationMap = { '1.0.0': cspRuleSchema, diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts similarity index 83% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts index 1cb08ddc1be1a..71e7697296acb 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts @@ -6,8 +6,8 @@ */ import type { ISavedObjectsRepository } from 'src/core/server'; -import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; -import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; +import { CIS_BENCHMARK_1_4_1_RULES } from './cis_1_4_1/rules'; +import { cspRuleAssetSavedObjectType } from '../../common/schemas/csp_rule'; export const initializeCspRules = async (client: ISavedObjectsRepository) => { const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index 6f48b15158f8d..0b4f30a137192 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const item: GetInfoResponse['item'] = { lens: [], map: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 6b766c2d126df..5c08120084cb9 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const item: GetInfoResponse['item'] = { lens: [], ml_module: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index b355a62fbf241..e9bb796626f58 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3585,7 +3585,8 @@ "map", "lens", "ml-module", - "security-rule" + "security-rule", + "csp-rule-template" ] }, "elasticsearch_asset_type": { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9a352f94e8252..f7941f863c120 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2238,6 +2238,7 @@ components: - lens - ml-module - security-rule + - csp_rule_template elasticsearch_asset_type: title: Elasticsearch asset type type: string diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml index 4ec82e7507166..1a7d29311e4fe 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml @@ -9,3 +9,4 @@ enum: - lens - ml-module - security-rule + - csp_rule_template diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 0cf8c3e88f568..ee47c3faa305a 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -25,6 +25,7 @@ describe('Fleet - packageToPackagePolicy', () => { path: '', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index dcff9f503bfe0..93be8684698ca 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -72,6 +72,7 @@ export enum KibanaAssetType { map = 'map', lens = 'lens', securityRule = 'security_rule', + cloudSecurityPostureRuleTemplate = 'csp_rule_template', mlModule = 'ml_module', tag = 'tag', } @@ -88,6 +89,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + cloudSecurityPostureRuleTemplate = 'csp-rule-template', tag = 'tag', } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index 8b949fe8634ee..f460005722b41 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -28,6 +28,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { = { tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { defaultMessage: 'Tag', }), + csp_rule_template: i18n.translate( + 'xpack.fleet.epm.assetTitles.cloudSecurityPostureRuleTemplate', + { + defaultMessage: 'Cloud Security Posture rule template', + } + ), }; export const ServiceTitleMap: Record = { @@ -89,6 +95,7 @@ export const AssetIcons: Record = { map: 'emsApp', lens: 'lensApp', security_rule: 'securityApp', + csp_rule_template: 'securityApp', // TODO ICON ml_module: 'mlApp', tag: 'tagApp', }; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e76e44476df03..491e4e27825c4 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -52,6 +52,8 @@ const KibanaSavedObjectTypeMapping: Record { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -170,6 +171,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -262,6 +264,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -386,6 +389,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index a44b8be478874..82b19cb02faf8 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -447,6 +447,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resCloudSecurityPostureRuleTemplate = await kibanaServer.savedObjects.get({ + type: 'csp-rule-template', + id: 'sample_csp_rule_template', + }); + expect(resCloudSecurityPostureRuleTemplate.id).equal('sample_csp_rule_template'); const resTag = await kibanaServer.savedObjects.get({ type: 'tag', id: 'sample_tag', @@ -496,8 +501,11 @@ const expectAssetsInstalled = ({ package_assets: sortBy(res.attributes.package_assets, (o: AssetReference) => o.type), }; expect(sortedRes).eql({ - installed_kibana_space_id: 'default', installed_kibana: [ + { + id: 'sample_csp_rule_template', + type: 'csp-rule-template', + }, { id: 'sample_dashboard', type: 'dashboard', @@ -535,6 +543,7 @@ const expectAssetsInstalled = ({ type: 'visualization', }, ], + installed_kibana_space_id: 'default', installed_es: [ { id: 'logs-all_assets.test_logs@mappings', @@ -593,37 +602,116 @@ const expectAssetsInstalled = ({ type: 'ml_model', }, ], + package_assets: [ + { + id: '333a22a1-e639-5af5-ae62-907ffc83d603', + type: 'epm-packages-assets', + }, + { + id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', + type: 'epm-packages-assets', + }, + { + id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', + type: 'epm-packages-assets', + }, + { + id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', + type: 'epm-packages-assets', + }, + { + id: '96c6eb85-fe2e-56c6-84be-5fda976796db', + type: 'epm-packages-assets', + }, + { + id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', + type: 'epm-packages-assets', + }, + { + id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', + type: 'epm-packages-assets', + }, + { + id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', + type: 'epm-packages-assets', + }, + { + id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', + type: 'epm-packages-assets', + }, + { + id: 'f839c76e-d194-555a-90a1-3265a45789e4', + type: 'epm-packages-assets', + }, + { + id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', + type: 'epm-packages-assets', + }, + { + id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', + type: 'epm-packages-assets', + }, + { + id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', + type: 'epm-packages-assets', + }, + { + id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', + type: 'epm-packages-assets', + }, + { + id: '943d5767-41f5-57c3-ba02-48e0f6a837db', + type: 'epm-packages-assets', + }, + { + id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', + type: 'epm-packages-assets', + }, + { + id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', + type: 'epm-packages-assets', + }, + { + id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', + type: 'epm-packages-assets', + }, + { + id: '318959c9-997b-5a14-b328-9fc7355b4b74', + type: 'epm-packages-assets', + }, + { + id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', + type: 'epm-packages-assets', + }, + { + id: '4c758d70-ecf1-56b3-b704-6d8374841b34', + type: 'epm-packages-assets', + }, + { + id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', + type: 'epm-packages-assets', + }, + { + id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', + type: 'epm-packages-assets', + }, + { + id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', + type: 'epm-packages-assets', + }, + { + id: '53c94591-aa33-591d-8200-cd524c2a0561', + type: 'epm-packages-assets', + }, + { + id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', + type: 'epm-packages-assets', + }, + ], es_index_patterns: { test_logs: 'logs-all_assets.test_logs-*', test_metrics: 'metrics-all_assets.test_metrics-*', }, - package_assets: [ - { id: '333a22a1-e639-5af5-ae62-907ffc83d603', type: 'epm-packages-assets' }, - { id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', type: 'epm-packages-assets' }, - { id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', type: 'epm-packages-assets' }, - { id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', type: 'epm-packages-assets' }, - { id: '96c6eb85-fe2e-56c6-84be-5fda976796db', type: 'epm-packages-assets' }, - { id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', type: 'epm-packages-assets' }, - { id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', type: 'epm-packages-assets' }, - { id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', type: 'epm-packages-assets' }, - { id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', type: 'epm-packages-assets' }, - { id: 'f839c76e-d194-555a-90a1-3265a45789e4', type: 'epm-packages-assets' }, - { id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', type: 'epm-packages-assets' }, - { id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', type: 'epm-packages-assets' }, - { id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', type: 'epm-packages-assets' }, - { id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', type: 'epm-packages-assets' }, - { id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', type: 'epm-packages-assets' }, - { id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', type: 'epm-packages-assets' }, - { id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', type: 'epm-packages-assets' }, - { id: '318959c9-997b-5a14-b328-9fc7355b4b74', type: 'epm-packages-assets' }, - { id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', type: 'epm-packages-assets' }, - { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, - { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, - { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, - { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, - { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, - { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, - ], name: 'all_assets', version: '0.1.0', removable: true, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 7d28b04c28a53..844a6abe3da06 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -393,6 +393,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_csp_rule_template2', + type: 'csp-rule-template', + }, { id: 'sample_ml_module', type: 'ml-module', @@ -488,6 +492,7 @@ export default function (providerContext: FtrProviderContext) { { id: '5c3aa147-089c-5084-beca-53c00e72ac80', type: 'epm-packages-assets' }, { id: '0c8c3c6a-90cb-5f0e-8359-d807785b046c', type: 'epm-packages-assets' }, { id: '48e582df-b1d2-5f88-b6ea-ba1fafd3a569', type: 'epm-packages-assets' }, + { id: '7f97600c-d983-53e0-ae2a-a59bf35d7f0d', type: 'epm-packages-assets' }, { id: 'bf3b0b65-9fdc-53c6-a9ca-e76140e56490', type: 'epm-packages-assets' }, { id: '7f4c5aca-b4f5-5f0a-95af-051da37513fc', type: 'epm-packages-assets' }, { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..cdcd06876e010 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.1", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..97a24faebb3fd --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.2", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template2", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 38c0d2593070d..c58666259dc07 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -66,6 +66,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.fleet.packages.0.version=latest`, ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), `--xpack.fleet.developer.bundledPackageLocation=${BUNDLED_PACKAGE_DIR}`, + '--xpack.cloudSecurityPosture.enabled=true', // Enable debug fleet logs by default `--logging.loggers[0].name=plugins.fleet`, `--logging.loggers[0].level=debug`, From 76321bceeb2d0a0b1d350f1b1b2593a9ee7c3cbb Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Thu, 24 Mar 2022 15:03:46 +0000 Subject: [PATCH 65/66] Update sec telemetry filterlist for diag alerts. (#128355) --- .../server/lib/telemetry/filterlists/endpoint_alerts.ts | 2 ++ .../security_solution/server/lib/telemetry/sender.test.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts index 3b55d4a789fc0..15f7b0a2a54c8 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts @@ -62,6 +62,8 @@ const allowlistBaseEventFields: AllowlistFields = { directory: true, hash: true, Ext: { + compressed_bytes: true, + compressed_bytes_present: true, code_signature: true, header_bytes: true, header_data: true, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index d055f3843d479..dff3676c20c8a 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -59,6 +59,8 @@ describe('TelemetryEventsSender', () => { test: 'me', another: 'nope', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', @@ -131,6 +133,8 @@ describe('TelemetryEventsSender', () => { created: 0, path: 'X', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', From d940231edcf80bf2239ab27d3dd3770db2a45981 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Thu, 24 Mar 2022 11:09:26 -0400 Subject: [PATCH 66/66] [CI] Try using spot instances for the hourly pipeline again (#128431) --- .buildkite/pipelines/hourly.yml | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index a236f9c37b313..1335866675564 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -19,24 +19,28 @@ steps: label: 'Default CI Group' parallelism: 27 agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 250 key: default-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: CI_GROUP=Docker .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Docker CI Group' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: default-cigroup-docker retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -44,78 +48,92 @@ steps: label: 'OSS CI Group' parallelism: 11 agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: oss-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_accessibility.sh label: 'OSS Accessibility Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_accessibility.sh label: 'Default Accessibility Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_firefox.sh label: 'OSS Firefox Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_firefox.sh label: 'Default Firefox Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_misc.sh label: 'OSS Misc Functional Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_saved_object_field_metrics.sh label: 'Saved Object Field Metrics' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -123,31 +141,47 @@ steps: label: 'Jest Tests' parallelism: 8 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 90 key: jest + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' parallelism: 3 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 120 key: jest-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/api_integration.sh label: 'API Integration Tests' agents: - queue: n2-2 + queue: n2-2-spot timeout_in_minutes: 120 key: api-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint.sh label: 'Linting' agents: - queue: n2-2 + queue: n2-2-spot key: linting timeout_in_minutes: 90 + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint_with_types.sh label: 'Linting (with types)' @@ -166,9 +200,13 @@ steps: - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: - queue: c2-4 + queue: n2-4-spot key: storybooks timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 - wait: ~ continue_on_failure: true