From 2a6c95caa11c5e32b006ad5c40aee9c49e1c4869 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 1 Feb 2024 08:18:54 -0500 Subject: [PATCH] move query tab to new file --- .../__snapshots__/index.test.tsx.snap | 1315 +++++++++++++++++ .../header/index.test.tsx | 174 +++ .../query_tab_content_new/header/index.tsx | 115 ++ .../query_tab_content_new/header/selectors.ts | 16 + .../header/translations.ts | 24 + .../query_tab_content_new/index.test.tsx | 241 +++ .../timeline/query_tab_content_new/index.tsx | 603 ++++++++ 7 files changed, 2488 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/selectors.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/translations.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.tsx diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000000..92591556d386400 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1315 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Timeline rendering renders correctly against snapshot 1`] = ` + +`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.test.tsx new file mode 100644 index 000000000000000..44d2ee6cd6a5469 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 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 { coreMock } from '@kbn/core/public/mocks'; +import { mockIndexPattern } from '../../../../../common/mock'; +import { TestProviders } from '../../../../../common/mock/test_providers'; +import { FilterManager } from '@kbn/data-plugin/public'; +import { mockDataProviders } from '../../data_providers/mock/mock_data_providers'; +import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; + +import { QueryTabHeader } from '.'; +import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline'; +import { waitFor } from '@testing-library/react'; +import { TimelineId } from '../../../../../../common/types'; + +const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; + +jest.mock('../../../../../common/lib/kibana'); + +describe('Header', () => { + const indexPattern = mockIndexPattern; + const mount = useMountAppended(); + const getWrapper = async (childrenComponent: JSX.Element) => { + const wrapper = mount(childrenComponent); + await waitFor(() => wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').exists()); + return wrapper; + }; + const props = { + browserFields: {}, + dataProviders: mockDataProviders, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + indexPattern, + onDataProviderEdited: jest.fn(), + onDataProviderRemoved: jest.fn(), + onToggleDataProviderEnabled: jest.fn(), + onToggleDataProviderExcluded: jest.fn(), + onToggleDataProviderType: jest.fn(), + show: true, + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.active, + timelineId: TimelineId.test, + timelineType: TimelineType.default, + }; + + describe('rendering', () => { + test('it renders the data providers when show is true', async () => { + const testProps = { ...props, show: true }; + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toEqual(true); + }); + + test('it renders the unauthorized call out providers', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').exists()).toEqual(true); + }); + + test('it renders the unauthorized call out with correct icon', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('iconType') + ).toEqual('warning'); + }); + + test('it renders the unauthorized call out with correct message', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('title') + ).toEqual( + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' + ); + }); + + test('it renders the immutable timeline call out providers', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineImmutableCallOut"]').exists()).toEqual(true); + }); + + test('it renders the immutable timeline call out with correct icon', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('iconType') + ).toEqual('warning'); + }); + + test('it renders the immutable timeline call out with correct message', async () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('title') + ).toEqual( + 'This prebuilt timeline template cannot be modified. To make changes, please duplicate this template and make modifications to the duplicate template.' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.tsx new file mode 100644 index 000000000000000..553b71705884b18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/index.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 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 { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import type { FilterManager } from '@kbn/data-plugin/public'; + +import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; +import styled from '@emotion/styled'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import type { TimelineStatusLiteralWithNull } from '../../../../../../common/api/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../../common/api/timeline'; +import { timelineSelectors } from '../../../../store'; +import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { timelineDefaults } from '../../../../store/defaults'; +import * as i18n from './translations'; +import { StatefulSearchOrFilter } from '../../search_or_filter'; +import { DataProviders } from '../../data_providers'; + +interface Props { + filterManager: FilterManager; + show: boolean; + showCallOutUnauthorizedMsg: boolean; + status: TimelineStatusLiteralWithNull; + timelineId: string; +} + +const DataProvidersContainer = styled.div<{ $shouldShowQueryBuilder: boolean }>` + position: relative; + width: 100%; + transition: 0.5s ease-in-out; + overflow: hidden; + + ${(props) => + props.$shouldShowQueryBuilder + ? `display: block; max-height: 300px; visibility: visible; margin-block-start: 0px;` + : `display: block; max-height: 0px; visibility: hidden; margin-block-start:-${euiThemeVars.euiSizeS};`} + + .${IS_DRAGGING_CLASS_NAME} & { + display: block; + max-height: 300px; + visibility: visible; + margin-block-start: 0px; + } +`; + +const QueryTabHeaderComponent: React.FC = ({ + filterManager, + show, + showCallOutUnauthorizedMsg, + status, + timelineId, +}) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const getIsDataProviderVisible = useMemo( + () => timelineSelectors.dataProviderVisibilitySelector(), + [] + ); + + const timelineType = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType + ); + + const isDataProviderVisible = useDeepEqualSelector( + (state) => getIsDataProviderVisible(state, timelineId) ?? timelineDefaults.isDataProviderVisible + ); + + const shouldShowQueryBuilder = isDataProviderVisible || timelineType === TimelineType.template; + + return ( + + + + + {showCallOutUnauthorizedMsg && ( + + + + )} + {status === TimelineStatus.immutable && ( + + + + )} + {show ? ( + + + + ) : null} + + ); +}; + +export const QueryTabHeader = React.memo(QueryTabHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/selectors.ts new file mode 100644 index 000000000000000..62c0134b9661232 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/selectors.ts @@ -0,0 +1,16 @@ +/* + * Copyright 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 { createSelector } from 'reselect'; + +import { timelineSelectors } from '../../../../store'; + +export const getTimelineSaveModalByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => ({ + showSaveModal: timeline?.showSaveModal ?? false, + status: timeline?.status, + })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/translations.ts new file mode 100644 index 000000000000000..f90c46d69d2305e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/header/translations.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate( + 'xpack.securitySolution.timeline.callOut.unauthorized.message.description', + { + defaultMessage: + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.', + } +); + +export const CALL_OUT_IMMUTABLE = i18n.translate( + 'xpack.securitySolution.timeline.callOut.immutable.message.description', + { + defaultMessage: + 'This prebuilt timeline template cannot be modified. To make changes, please duplicate this template and make modifications to the duplicate template.', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.test.tsx new file mode 100644 index 000000000000000..1fe1f2ebbfa7d60 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.test.tsx @@ -0,0 +1,241 @@ +/* + * Copyright 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 { shallow } from 'enzyme'; +import React from 'react'; +import useResizeObserver from 'use-resize-observer/polyfilled'; + +import { DefaultCellRenderer } from '../cell_rendering/default_cell_renderer'; +import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock/test_providers'; + +import type { Props as QueryTabContentComponentProps } from '.'; +import { QueryTabContentComponent } from '.'; +import { defaultRowRenderers } from '../body/renderers'; +import type { Sort } from '../body/sort'; +import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; +import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineStatus } from '../../../../../common/api/timeline'; +import { useTimelineEvents } from '../../../containers'; +import { useTimelineEventsDetails } from '../../../containers/details'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; +import { Direction } from '../../../../../common/search_strategy'; +import * as helpers from '../../../../common/lib/kuery'; +import { waitFor } from '@testing-library/react'; + +jest.mock('../../../containers', () => ({ + useTimelineEvents: jest.fn(), +})); +jest.mock('../../../containers/details', () => ({ + useTimelineEventsDetails: jest.fn(), +})); +jest.mock('../../fields_browser', () => ({ + useFieldBrowserOptions: jest.fn(), +})); +jest.mock('../body/events', () => ({ + Events: () => <>, +})); + +jest.mock('../../../../common/containers/sourcerer'); +jest.mock('../../../../common/containers/sourcerer/use_signal_helpers', () => ({ + useSignalHelpers: () => ({ signalIndexNeedsInit: false }), +})); + +jest.mock('../../../../common/lib/kuery'); + +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer/polyfilled'); +mockUseResizeObserver.mockImplementation(() => ({})); + +jest.mock('../../../../common/lib/kibana'); + +describe('Timeline', () => { + let props = {} as QueryTabContentComponentProps; + const sort: Sort[] = [ + { + columnId: '@timestamp', + columnType: 'date', + esTypes: ['date'], + sortDirection: Direction.desc, + }, + ]; + const startDate = '2018-03-23T18:49:23.132Z'; + const endDate = '2018-03-24T03:33:52.253Z'; + + const mount = useMountAppended(); + const getWrapper = async (childrenComponent: JSX.Element) => { + const wrapper = mount(childrenComponent); + await waitFor(() => wrapper.find('[data-test-subj="timelineHeader"]').exists()); + return wrapper; + }; + beforeEach(() => { + (useTimelineEvents as jest.Mock).mockReturnValue([ + false, + { + events: mockTimelineData, + pageInfo: { + activePage: 0, + totalPages: 10, + }, + }, + ]); + (useTimelineEventsDetails as jest.Mock).mockReturnValue([false, {}]); + + (useSourcererDataView as jest.Mock).mockReturnValue(mockSourcererScope); + + props = { + columns: defaultHeaders, + dataProviders: mockDataProviders, + end: endDate, + expandedDetail: {}, + filters: [], + timelineId: TimelineId.test, + isLive: false, + itemsPerPage: 5, + itemsPerPageOptions: [5, 10, 20], + kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], + kqlQueryExpression: ' ', + kqlQueryLanguage: 'kuery', + onEventClosed: jest.fn(), + renderCellValue: DefaultCellRenderer, + rowRenderers: defaultRowRenderers, + showCallOutUnauthorizedMsg: false, + showExpandedDetails: false, + sort, + start: startDate, + status: TimelineStatus.active, + timerangeKind: 'absolute', + activeTab: TimelineTabs.query, + show: true, + }; + }); + + // FLAKY: https://github.com/elastic/kibana/issues/156797 + describe.skip('rendering', () => { + let spyCombineQueries: jest.SpyInstance; + + beforeEach(() => { + spyCombineQueries = jest.spyOn(helpers, 'combineQueries'); + }); + afterEach(() => { + spyCombineQueries.mockClear(); + }); + + test('should trim kqlQueryExpression', async () => { + await getWrapper( + + + + ); + + expect(spyCombineQueries.mock.calls[0][0].kqlQuery.query).toEqual( + props.kqlQueryExpression.trim() + ); + }); + + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.find('QueryTabContentComponent')).toMatchSnapshot(); + }); + + test('it renders the timeline header', async () => { + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineHeader"]').exists()).toEqual(true); + }); + + test('it renders the timeline table', async () => { + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find(`[data-test-subj="${TimelineTabs.query}-events-table"]`).exists() + ).toEqual(true); + }); + + test('it does NOT render the timeline table when start is empty', async () => { + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find(`[data-test-subj="${TimelineTabs.query}-events-table"]`).exists() + ).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); + }); + + test('it does NOT render the timeline table when end is empty', async () => { + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find(`[data-test-subj="${TimelineTabs.query}-events-table"]`).exists() + ).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); + }); + + test('it does NOT render the paging footer when you do NOT have any data providers', async () => { + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="table-pagination"]').exists()).toEqual(false); + }); + + it('it shows the timeline footer', async () => { + const wrapper = await getWrapper( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(true); + }); + + test('it does render the timeline table when the source is loading with no events', async () => { + (useSourcererDataView as jest.Mock).mockReturnValue({ + browserFields: {}, + loading: true, + indexPattern: {}, + selectedPatterns: [], + missingPatterns: [], + }); + const wrapper = await getWrapper( + + + + ); + + expect( + wrapper.find(`[data-test-subj="${TimelineTabs.query}-events-table"]`).exists() + ).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.tsx new file mode 100644 index 000000000000000..1d2527ae641afd3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content_new/index.tsx @@ -0,0 +1,603 @@ +/* + * Copyright 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, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiBadge, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useEffect, useCallback, useState } from 'react'; +import styled from 'styled-components'; +import type { Dispatch } from 'redux'; +import type { ConnectedProps } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { InPortal } from 'react-reverse-portal'; + +import { FilterManager } from '@kbn/data-plugin/public'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import { DataLoadingState } from '@kbn/unified-data-table'; +import { RootDragDropProvider } from '@kbn/dom-drag-drop'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import type { ControlColumnProps } from '../../../../../common/types'; +import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { useInvalidFilterQuery } from '../../../../common/hooks/use_invalid_filter_query'; +import { timelineActions, timelineSelectors } from '../../../store'; +import type { CellValueElementProps } from '../cell_rendering'; +import type { Direction, TimelineItem } from '../../../../../common/search_strategy'; +import { useTimelineEvents } from '../../../containers'; +import { useKibana } from '../../../../common/lib/kibana'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import { StatefulBody } from '../body'; +import { Footer, footerHeight } from '../footer'; +import { QueryTabHeader } from './header'; +import { calculateTotalPages } from '../helpers'; +import { combineQueries } from '../../../../common/lib/kuery'; +import { TimelineRefetch } from '../refetch_timeline'; +import type { + KueryFilterQueryKind, + RowRenderer, + ToggleDetailPanel, +} from '../../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; +import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; +import type { inputsModel, State } from '../../../../common/store'; +import { inputsSelectors } from '../../../../common/store'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { timelineDefaults } from '../../../store/defaults'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; +import type { TimelineModel } from '../../../store/model'; +import { DetailsPanel } from '../../side_panel'; +import { getDefaultControlColumn } from '../body/control_columns'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useLicense } from '../../../../common/hooks/use_license'; +import { HeaderActions } from '../../../../common/components/header_actions/header_actions'; +import { UnifiedTimelineComponent } from '../unified_components'; +import { defaultUdtHeaders } from '../unified_components/default_headers'; +import { StyledTableFlexGroup, StyledTableFlexItem } from '../unified_components/styles'; +import { activeTimeline } from '../../../containers/active_timeline_context'; + +const TimelineHeaderContainer = styled.div` + margin-top: 6px; +`; + +const QueryTabHeaderContainer = styled.div` + width: 100%; +`; + +QueryTabHeaderContainer.displayName = 'TimelineHeaderContainer'; + +const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` + align-items: stretch; + box-shadow: none; + display: flex; + flex-direction: column; +`; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + padding: 0; + height: 100%; + display: flex; + } +`; + +const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` + background: none; + &.euiFlyoutFooter { + ${({ theme }) => `padding: ${theme.eui.euiSizeS} 0;`} + } +`; + +const FullWidthFlexGroup = styled(EuiFlexGroup)` + margin: 0; + width: 100%; + overflow: hidden; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `margin: 0 ${theme.eui.euiSizeM};`} + overflow: hidden; +`; + +const SourcererFlex = styled(EuiFlexItem)` + align-items: flex-end; +`; + +SourcererFlex.displayName = 'SourcererFlex'; + +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + +VerticalRule.displayName = 'VerticalRule'; + +const EventsCountBadge = styled(EuiBadge)` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const isTimerangeSame = (prevProps: Props, nextProps: Props) => + prevProps.end === nextProps.end && + prevProps.start === nextProps.start && + prevProps.timerangeKind === nextProps.timerangeKind; + +const compareQueryProps = (prevProps: Props, nextProps: Props) => + prevProps.kqlMode === nextProps.kqlMode && + prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && + deepEqual(prevProps.filters, nextProps.filters); + +interface OwnProps { + renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + timelineId: string; +} + +const EMPTY_EVENTS: TimelineItem[] = []; + +export type Props = OwnProps & PropsFromRedux; + +const trailingControlColumns: ControlColumnProps[] = []; // stable reference + +export const QueryTabContentComponent: React.FC = ({ + activeTab, + columns, + dataProviders, + end, + expandedDetail, + filters, + timelineId, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + kqlQueryExpression, + kqlQueryLanguage, + onEventClosed, + renderCellValue, + rowRenderers, + show, + showCallOutUnauthorizedMsg, + showExpandedDetails, + start, + status, + sort, + timerangeKind, +}) => { + const dispatch = useDispatch(); + const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled( + 'unifiedComponentsInTimelineEnabled' + ); + + const [pageRows, setPageRows] = useState([]); + const rows = useMemo(() => pageRows.flat(), [pageRows]); + const { portalNode: timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + const { + browserFields, + dataViewId, + loading: isSourcererLoading, + indexPattern, + runtimeMappings, + // important to get selectedPatterns from useSourcererDataView + // in order to include the exclude filters in the search that are not stored in the timeline + selectedPatterns, + } = useSourcererDataView(SourcererScopeName.timeline); + + const { uiSettings } = useKibana().services; + const isEnterprisePlus = useLicense().isEnterprise(); + const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; + + const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + + const { sampleSize = 10, filterManager: activeFilterManager } = useDeepEqualSelector((state) => + getManageTimeline(state, timelineId ?? TimelineId.active) + ); + + const filterManager = useMemo( + () => activeFilterManager ?? new FilterManager(uiSettings), + [activeFilterManager, uiSettings] + ); + + const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); + const kqlQuery: { + query: string; + language: KueryFilterQueryKind; + } = useMemo( + () => ({ query: kqlQueryExpression.trim(), language: kqlQueryLanguage }), + [kqlQueryExpression, kqlQueryLanguage] + ); + + const combinedQueries = combineQueries({ + config: esQueryConfig, + dataProviders, + indexPattern, + browserFields, + filters, + kqlQuery, + kqlMode, + }); + + useInvalidFilterQuery({ + id: timelineId, + filterQuery: combinedQueries?.filterQuery, + kqlError: combinedQueries?.kqlError, + query: kqlQuery, + startDate: start, + endDate: end, + }); + + const isBlankTimeline: boolean = + isEmpty(dataProviders) && + isEmpty(filters) && + isEmpty(kqlQuery.query) && + combinedQueries?.filterQuery === undefined; + + const canQueryTimeline = useMemo( + () => + combinedQueries != null && + isSourcererLoading != null && + !isSourcererLoading && + !isEmpty(start) && + !isEmpty(end) && + combinedQueries?.filterQuery !== undefined, + [combinedQueries, end, isSourcererLoading, start] + ); + + const columnsHeader = useMemo(() => { + return isEmpty(columns) + ? unifiedComponentsInTimelineEnabled + ? defaultUdtHeaders + : defaultHeaders + : columns; + }, [columns, unifiedComponentsInTimelineEnabled]); + + const defaultColumns = useMemo(() => { + return columnsHeader.map((c) => c.id); + }, [columnsHeader]); + + const getTimelineQueryFields = useCallback(() => { + return [...defaultColumns, ...requiredFieldsForActions]; + }, [defaultColumns]); + + const timelineQuerySortField = sort.map(({ columnId, columnType, esTypes, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + esTypes: esTypes ?? [], + type: columnType, + })); + + useEffect(() => { + dispatch( + timelineActions.initializeTimelineSettings({ + filterManager, + id: timelineId, + }) + ); + }, [dispatch, filterManager, timelineId]); + + const limit = useMemo( + () => (unifiedComponentsInTimelineEnabled ? sampleSize : itemsPerPage), + [itemsPerPage, unifiedComponentsInTimelineEnabled, sampleSize] + ); + + const [ + dataLoadingState, + { events, inspect, totalCount, refetch, loadPage, pageInfo, refreshedAt }, + ] = useTimelineEvents({ + dataViewId, + endDate: end, + fields: getTimelineQueryFields(), + filterQuery: combinedQueries?.filterQuery, + id: timelineId, + indexNames: selectedPatterns, + language: kqlQuery.language, + limit, + runtimeMappings, + skip: !canQueryTimeline, + sort: timelineQuerySortField, + startDate: start, + timerangeKind, + }); + + const isQueryLoading = useMemo(() => { + return ( + dataLoadingState === DataLoadingState.loading || + dataLoadingState === DataLoadingState.loadingMore + ); + }, [dataLoadingState]); + + useEffect(() => { + setPageRows((currentPageRows) => { + if (pageInfo.activePage !== 0 && currentPageRows[pageInfo.activePage]?.length) { + return currentPageRows; + } + const newPageRows = pageInfo.activePage === 0 ? [] : [...currentPageRows]; + newPageRows[pageInfo.activePage] = events; + return newPageRows; + }); + }, [events, pageInfo.activePage]); + + const handleOnPanelClosed = useCallback(() => { + onEventClosed({ tabType: TimelineTabs.query, id: timelineId }); + + if ( + expandedDetail[TimelineTabs.query]?.panelView && + timelineId === TimelineId.active && + showExpandedDetails + ) { + activeTimeline.toggleExpandedDetail({}); + } + }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); + + useEffect(() => { + dispatch( + timelineActions.updateIsLoading({ + id: timelineId, + isLoading: isQueryLoading || isSourcererLoading, + }) + ); + }, [isSourcererLoading, timelineId, dispatch, dataLoadingState, isQueryLoading]); + + const leadingControlColumns = useMemo( + () => + getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ + ...x, + headerCellRender: HeaderActions, + })), + [ACTION_BUTTON_COUNT] + ); + + const header = useMemo( + () => ( + + + + + + ), + [activeTab, filterManager, show, showCallOutUnauthorizedMsg, status, timelineId] + ); + + // NOTE: The timeline is blank after browser FORWARD navigation (after using back button to navigate to + // the previous page from the timeline), yet we still see total count. This is because the timeline + // is not getting refreshed when using browser navigation. + const showEventsCountBadge = !isBlankTimeline && totalCount >= 0; + + return ( + <> + + {showEventsCountBadge ? ( + + {totalCount} + + ) : null} + + + {unifiedComponentsInTimelineEnabled ? ( + + {header} + + + + + + + ) : ( + + + {header} + + + + + + {!isBlankTimeline && ( +