From 3d798c98d1d238c5efc214921b9f13c52ad84a1d Mon Sep 17 00:00:00 2001 From: YulNaumenko Date: Thu, 7 Sep 2023 12:55:41 -0700 Subject: [PATCH 01/78] [Security Solution] Integrate UnifiedDataTable to Timeline tabs --- .../common/types/header_actions/index.ts | 2 +- .../components/header_actions/actions.tsx | 83 +- .../header_actions/header_actions.tsx | 6 +- .../common/lib/cell_actions/field_value.tsx | 2 +- .../common/lib/cell_actions/helpers.tsx | 2 +- .../components/open_timeline/helpers.ts | 2 +- .../body/column_headers/default_headers.ts | 8 +- .../body/column_headers/helpers.test.ts | 17 +- .../timeline/body/column_headers/helpers.ts | 5 - .../components/timeline/body/helpers.tsx | 1 + .../body/renderers/formatted_field.tsx | 167 +++- .../timeline/data_table/data_table.tsx | 891 ++++++++++++++++++ .../timeline/query_tab_content/index.tsx | 233 +++-- .../timeline/tabs_content/index.tsx | 2 +- .../timelines/store/timeline/actions.ts | 6 + .../timelines/store/timeline/helpers.ts | 27 + .../timelines/store/timeline/reducer.ts | 11 + 17 files changed, 1218 insertions(+), 247 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/data_table/data_table.tsx diff --git a/x-pack/plugins/security_solution/common/types/header_actions/index.ts b/x-pack/plugins/security_solution/common/types/header_actions/index.ts index d7b5af94ac429..a1e3b697c9e62 100644 --- a/x-pack/plugins/security_solution/common/types/header_actions/index.ts +++ b/x-pack/plugins/security_solution/common/types/header_actions/index.ts @@ -137,7 +137,7 @@ export interface ActionProps { isEventPinned?: boolean; isEventViewer?: boolean; loadingEventIds: Readonly; - onEventDetailsPanelOpened: () => void; + onEventDetailsPanelOpened?: () => void; onRowSelected: OnRowSelected; onRuleChange?: () => void; refetch?: () => void; diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index c3e4b25d01b83..477d0f5f8a8b4 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; import { TimelineTabs, TableId } from '@kbn/securitysolution-data-table'; @@ -31,43 +31,32 @@ import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use import { ALERTS_ACTIONS } from '../../lib/apm/user_actions'; import { setActiveTabTimeline } from '../../../timelines/store/timeline/actions'; import { EventsTdContent } from '../../../timelines/components/timeline/styles'; -import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { AlertContextMenu } from '../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; import * as i18n from './translations'; -import { useTourContext } from '../guided_onboarding_tour'; -import { AlertsCasesTourSteps, SecurityStepId } from '../guided_onboarding_tour/tour_config'; -import { isDetectionsAlertsTable } from '../top_n/helpers'; -import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; import { DEFAULT_ACTION_BUTTON_WIDTH, isAlert } from './helpers'; const ActionsContainer = styled.div` align-items: center; display: flex; + height: 25px; `; const ActionsComponent: React.FC = ({ ariaRowindex, - checked, columnValues, ecsData, eventId, eventIdToNoteIds, isEventPinned = false, isEventViewer = false, - loadingEventIds, - onEventDetailsPanelOpened, - onRowSelected, onRuleChange, - showCheckboxes, showNotes, timelineId, toggleShowNotes, refetch, - setEventsLoading, }) => { const dispatch = useDispatch(); - const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const emptyNotes: string[] = []; const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const timelineType = useShallowEqualSelector( @@ -88,15 +77,6 @@ const ActionsComponent: React.FC = ({ [dispatch, timelineId] ); - const handleSelectEvent = useCallback( - (event: React.ChangeEvent) => - onRowSelected({ - eventIds: [eventId], - isSelected: event.currentTarget.checked, - }), - [eventId, onRowSelected] - ); - const handlePinClicked = useCallback( () => getPinOnClick({ @@ -204,67 +184,8 @@ const ActionsComponent: React.FC = ({ scopedActions, ]); - const { activeStep, isTourShown, incrementStep } = useTourContext(); - - const isTourAnchor = useMemo( - () => - isTourShown(SecurityStepId.alertsCases) && - eventType === 'signal' && - isDetectionsAlertsTable(timelineId) && - ariaRowindex === 1, - [isTourShown, ariaRowindex, eventType, timelineId] - ); - - const onExpandEvent = useCallback(() => { - if ( - isTourAnchor && - activeStep === AlertsCasesTourSteps.expandEvent && - isTourShown(SecurityStepId.alertsCases) - ) { - incrementStep(SecurityStepId.alertsCases); - } - onEventDetailsPanelOpened(); - }, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]); - return ( - {showCheckboxes && !tGridEnabled && ( -
- - {loadingEventIds.includes(eventId) ? ( - - ) : ( - - )} - -
- )} - -
- - - - - -
-
<> {timelineId !== TimelineId.active && ( = ({ const dispatch = useDispatch(); const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { defaultColumns } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId)); + const { defaultColumns } = useDeepEqualSelector( + (state) => getManageTimeline(state, timelineId ?? TimelineId.active) // TODO fix + ); const toggleFullScreen = useCallback(() => { if (timelineId === TimelineId.active) { @@ -215,7 +217,7 @@ const HeaderActionsComponent: React.FC = ({ {triggersActionsUi.getFieldBrowser({ browserFields, - columnIds: columnHeaders.map(({ id }) => id), + columnIds: (columnHeaders ?? []).map(({ id }) => id), onResetColumns, onToggleColumn, options: fieldBrowserOptions, diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/field_value.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/field_value.tsx index 087732479c301..6c472a81cd9c3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/field_value.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/field_value.tsx @@ -19,7 +19,7 @@ import { parseValue } from '../../../timelines/components/timeline/body/renderer import { EmptyComponent, getLinkColumnDefinition } from './helpers'; import { getField, getFieldKey } from '../../../helpers'; -const useFormattedFieldProps = ({ +export const useFormattedFieldProps = ({ rowIndex, pageSize, ecsData, diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.tsx index 4413de85a0ed8..3e6c993058036 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.tsx @@ -10,12 +10,12 @@ import * as i18n from './translations'; import { EVENT_URL_FIELD_NAME, HOST_NAME_FIELD_NAME, + IP_FIELD_TYPE, REFERENCE_URL_FIELD_NAME, RULE_REFERENCE_FIELD_NAME, USER_NAME_FIELD_NAME, } from '../../../timelines/components/timeline/body/renderers/constants'; import { INDICATOR_REFERENCE } from '../../../../common/cti/constants'; -import { IP_FIELD_TYPE } from '../../../explore/network/components/ip'; import { PORT_NAMES } from '../../../explore/network/components/port/helpers'; import { useKibana } from '../kibana'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index ca43ba15dbf31..79b1bff120d3f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -119,7 +119,7 @@ const setTimelineColumn = (col: ColumnHeaderResult) => columnHeaderType: defaultColumnHeaderType, id: col.id != null ? col.id : 'unknown', initialWidth: - col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : undefined, } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts index 56c29462415bd..5623ad0ca0764 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts @@ -21,36 +21,30 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: 'message', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH * 2, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 317400f3bae90..5857514e3638f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -11,24 +11,9 @@ import type { BrowserFields } from '../../../../../../common/search_strategy'; import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; import { defaultHeaders } from './default_headers'; -import { - getColumnWidthFromType, - getColumnHeaders, - getRootCategory, - getColumnHeader, -} from './helpers'; +import { getColumnHeaders, getRootCategory, getColumnHeader } from './helpers'; describe('helpers', () => { - describe('getColumnWidthFromType', () => { - test('it returns the expected width for a non-date column', () => { - expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH); - }); - - test('it returns the expected width for a date column', () => { - expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH); - }); - }); - describe('getRootCategory', () => { const baseFields = ['@timestamp', '_id', 'message']; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index 0ecfcb16f7126..19dd1838de212 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -9,7 +9,6 @@ import { has, get } from 'lodash/fp'; import type { ColumnHeaderOptions } from '../../../../../../common/types'; import type { BrowserFields } from '../../../../../common/containers/source'; -import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; import { defaultColumnHeaderType } from './default_headers'; /** @@ -49,9 +48,6 @@ export const getColumnHeaders = ( : []; }; -export const getColumnWidthFromType = (type: string): number => - type !== 'date' ? DEFAULT_COLUMN_MIN_WIDTH : DEFAULT_DATE_COLUMN_MIN_WIDTH; - /** * Returns the column header with field details from the defaultHeaders */ @@ -61,6 +57,5 @@ export const getColumnHeader = ( ): ColumnHeaderOptions => ({ columnHeaderType: defaultColumnHeaderType, id: fieldName, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, ...(defaultHeaders.find((c) => c.id === fieldName) ?? {}), }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index fe309d847df80..7eee5a7b76f2a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -71,6 +71,7 @@ export const getPinOnClick = ({ onUnPinEvent, isEventPinned, }: GetPinOnClickParams) => { + console.log(isEventPinned); if (!allowUnpinning) { return; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index bf216c55f721f..eeb3cf4b9085b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -7,10 +7,16 @@ /* eslint-disable complexity */ -import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import type { + EuiButtonEmpty, + EuiButtonIcon, + EuiDataGridCellValueElementProps, + EuiDataGridColumnCellActionProps, +} from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { isNumber, isEmpty } from 'lodash/fp'; -import React from 'react'; +import { head, getOr, get, isEmpty, isNumber } from 'lodash/fp'; +import React, { useMemo } from 'react'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { EndpointAgentStatusById } from '../../../../../common/components/endpoint/endpoint_agent_status'; import { INDICATOR_REFERENCE } from '../../../../../../common/cti/constants'; @@ -42,10 +48,165 @@ import { RenderRuleName, renderEventModule, renderUrl } from './formatted_field_ import { RuleStatus } from './rule_status'; import { HostName } from './host_name'; import { UserName } from './user_name'; +import { + EmptyComponent, + getLinkColumnDefinition, +} from '../../../../../common/lib/cell_actions/helpers'; +import { + ColumnHeaderOptions, + TimelineItem, + TimelineNonEcsData, +} from '@kbn/timelines-plugin/common'; +import { DataTableRecord } from '@kbn/discover-utils/types'; +import { useGetMappedNonEcsValue } from '../data_driven_columns'; +import { parseValue } from './parse_value'; +import { getField, getFieldKey } from '../../../../../helpers'; // simple black-list to prevent dragging and dropping fields such as message name const columnNamesNotDraggable = [MESSAGE_FIELD_NAME]; +export const useFormattedFieldProps1 = ({ + dataTableRow, + columnId, + header, +}: { + dataTableRow: DataTableRecord & TimelineItem; + header?: ColumnHeaderOptions; + columnId: string; +}) => { + const ecs = dataTableRow.ecs; + const link = getLinkColumnDefinition(columnId, header?.type, header?.linkField); + const linkField = header?.linkField ? header?.linkField : link?.linkField; + const linkValues = header && getOr([], linkField ?? '', ecs); + const eventId = (header && get('_id' ?? '', ecs)) || ''; + const rowData = useMemo(() => { + return { + data: dataTableRow, + fieldName: columnId, + }; + }, [columnId]); + + const values = useGetMappedNonEcsValue({ data: rowData.data.data, fieldName: rowData.fieldName }); + const value = parseValue(head(values)); + const title = values && values.length > 1 ? `${link?.label}: ${value}` : link?.label; + // if linkField is defined but link values is empty, it's possible we are trying to look for a column definition for an old event set + if (linkField !== undefined && linkValues.length === 0 && values !== undefined) { + const normalizedLinkValue = getField(ecs, linkField); + const normalizedLinkField = getFieldKey(ecs, linkField); + const normalizedColumnId = getFieldKey(ecs, columnId); + const normalizedLink = getLinkColumnDefinition( + normalizedColumnId, + header?.type, + normalizedLinkField + ); + return { + link: normalizedLink, + eventId, + fieldFormat: header?.format || '', + fieldName: normalizedColumnId, + fieldType: header?.type || '', + value: parseValue(head(normalizedColumnId)), + values, + title, + linkValue: head(normalizedLinkValue), + }; + } else { + return { + link, + eventId, + fieldFormat: header?.format || '', + fieldName: columnId, + fieldType: header?.type || '', + value, + values, + title, + linkValue: head(linkValues), + }; + } +}; + +export const getFormattedFields = ({ + dataTableRows, + headers, + scopeId, + closeCellPopover, +}: { + dataTableRows: Array; + headers?: ColumnHeaderOptions[]; + scopeId: string; + closeCellPopover?: () => void; +}) => { + console.log(headers) + + return [ + ...PORT_NAMES, + EVENT_DURATION_FIELD_NAME, + HOST_NAME_FIELD_NAME, + USER_NAME_FIELD_NAME, + SIGNAL_RULE_NAME_FIELD_NAME, + EVENT_MODULE_FIELD_NAME, + SIGNAL_STATUS_FIELD_NAME, + AGENT_STATUS_FIELD_NAME, + RULE_REFERENCE_FIELD_NAME, + REFERENCE_URL_FIELD_NAME, + EVENT_URL_FIELD_NAME, + INDICATOR_REFERENCE, + ].reduce( + ( + obj: Record React.ReactNode>, + field: string + ) => { + obj[field] = (props: EuiDataGridCellValueElementProps) => { + const header = headers?.find((h) => h.id === props.columnId); + const { + link, + eventId, + value, + values, + title, + fieldName, + fieldFormat, + fieldType, + linkValue, + } = useFormattedFieldProps1({ + dataTableRow: dataTableRows[props.rowIndex], + columnId: props.columnId, + header, + }); + + const showEmpty = useMemo(() => { + const hasLink = link !== undefined && values && !isEmpty(value); + return hasLink !== true; + }, [link, value, values]); + + return showEmpty === false ? ( + + ) : ( + // data grid expects each cell action always return an element, it crashes if returns null + EmptyComponent + ); + }; + return obj; + }, + {} + ); +}; + const FormattedFieldValueComponent: React.FC<{ asPlainText?: boolean; /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_table/data_table.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_table/data_table.tsx new file mode 100644 index 0000000000000..b0252f8c75f94 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_table/data_table.tsx @@ -0,0 +1,891 @@ +/* + * Copyright 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 { + EuiDataGridCellProps, + EuiDataGridCellValueElementProps, + EuiDataGridColumn, + EuiDataGridControlColumn, + EuiDataGridProps, +} from '@elastic/eui'; +import { + logicalCSS, + useEuiTheme, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiButtonIcon, +} from '@elastic/eui'; +import type { JSXElementConstructor } from 'react'; +import React, { useMemo, useEffect, useCallback, useRef, useState } from 'react'; +import { css } from 'styled-components'; +import { useDispatch } from 'react-redux'; + +import { generateFilters } from '@kbn/data-plugin/public'; +import type { DataViewField } from '@kbn/data-plugin/common'; +import { flattenHit } from '@kbn/data-plugin/common'; +import { DataView } from '@kbn/data-views-plugin/public'; +import type { DataTableRecord, DocViewFilterFn } from '@kbn/discover-utils/types'; +import type { UnifiedDataTableSettingsColumn } from '@kbn/unified-data-table'; +import { DataLoadingState, UnifiedDataTable, useColumns } from '@kbn/unified-data-table'; +import type { SortOrder } from '@kbn/saved-search-plugin/public'; +import { popularizeField } from '@kbn/unified-data-table/src/utils/popularize_field'; +import { i18n } from '@kbn/i18n'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../common/constants'; +import { EXIT_FULL_SCREEN } from '../../../../common/components/exit_full_screen/translations'; +import { isActiveTimeline } from '../../../../helpers'; +import type { + ExpandedDetailTimeline, + ExpandedDetailType, + SetEventsDeleted, + SetEventsLoading, +} from '../../../../../common/types'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import type { TimelineItem } from '../../../../../common/search_strategy'; +import { useKibana } from '../../../../common/lib/kibana'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import type { + ColumnHeaderOptions, + OnChangePage, + RowRenderer, + SortColumnTimeline, + ToggleDetailPanel, +} from '../../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; +import type { inputsModel } from '../../../../common/store'; +import { appSelectors } from '../../../../common/store'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../common/containers/use_full_screen'; +import { activeTimeline } from '../../../containers/active_timeline_context'; +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 { SecurityCellActionsTrigger } from '../../../../actions/constants'; +import { StatefulRowRenderer } from '../body/events/stateful_row_renderer'; +import { RowRendererId } from '../../../../../common/api/timeline'; +import { Actions } from '../../../../common/components/header_actions/actions'; +import { plainRowRenderer } from '../body/renderers/plain_row_renderer'; +import { useFieldBrowserOptions } from '../../fields_browser'; +import { getColumnHeader } from '../body/column_headers/helpers'; +import { StatefulRowRenderersBrowser } from '../../row_renderers_browser'; +import { eventIsPinned } from '../body/helpers'; +import { NOTES_BUTTON_CLASS_NAME } from '../properties/helpers'; +import { EventsTrSupplement } from '../styles'; +import { NoteCards } from '../../notes/note_cards'; +import type { TimelineResultNote } from '../../open_timeline/types'; +import { getFormattedFields } from '../body/renderers/formatted_field'; + +export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullScreenButton', { + defaultMessage: 'Full screen', +}); +/** This offset begins at two, because the header row counts as "row 1", and aria-rowindex starts at "1" */ +const ARIA_ROW_INDEX_OFFSET = 2; +const SAMPLE_SIZE_SETTING = 500; +const DataGridMemoized = React.memo(UnifiedDataTable); + +export const isFullScreen = ({ + globalFullScreen, + isActiveTimelines, + timelineFullScreen, +}: { + globalFullScreen: boolean; + isActiveTimelines: boolean; + timelineFullScreen: boolean; +}) => + (isActiveTimelines && timelineFullScreen) || (isActiveTimelines === false && globalFullScreen); + +interface Props { + columns: ColumnHeaderOptions[]; + // renderCellValue: (props: CellValueElementProps) => React.ReactNode; + rowRenderers: RowRenderer[]; + timelineId: string; + itemsPerPage: number; + itemsPerPageOptions: number[]; + sort: SortColumnTimeline[]; + events: TimelineItem[]; + refetch: inputsModel.Refetch; + isQueryLoading: boolean; + totalCount: number; + onEventClosed: (args: ToggleDetailPanel) => void; + expandedDetail: ExpandedDetailTimeline; + showExpandedDetails: boolean; + onChangePage: OnChangePage; +} + +export const TimelineDataTableComponent: React.FC = ({ + columns, + timelineId, + itemsPerPage, + itemsPerPageOptions, + rowRenderers, + sort, + events, + refetch, + isQueryLoading, + totalCount, + onEventClosed, + showExpandedDetails, + expandedDetail, + onChangePage, +}) => { + const dispatch = useDispatch(); + const { euiTheme } = useEuiTheme(); + + const { + services: { + uiSettings, + fieldFormats, + dataViews, + storage, + dataViewFieldEditor, + notifications: { toasts: toastsService }, + application: { capabilities }, + theme, + triggersActionsUi, + data: dataPluginContract, + }, + } = useKibana(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); + + const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); + const [fetchedPage, setFechedPage] = useState(0); + const trGroupRef = useRef(null); + + const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const currentTimeline = useDeepEqualSelector((state) => + getManageTimeline(state, timelineId ?? TimelineId.active) + ); + + const defaultColumns = useMemo(() => { + return columns.map((c) => c.id); + }, [columns]); + const { browserFields, runtimeMappings, sourcererDataView } = useSourcererDataView( + SourcererScopeName.timeline + ); + + const dataView = useMemo(() => { + if (sourcererDataView != null) { + return new DataView({ spec: sourcererDataView, fieldFormats }); + } else { + return undefined; + } + }, [sourcererDataView, fieldFormats]); + + const onToggleShowNotes = useCallback((event: DataTableRecord) => { + const eventId = event.id; + + setShowNotes((prevShowNotes) => { + if (prevShowNotes[eventId]) { + // notes are closing, so focus the notes button on the next tick, after escaping the EuiFocusTrap + setTimeout(() => { + const notesButtonElement = trGroupRef.current?.querySelector( + `.${NOTES_BUTTON_CLASS_NAME}` + ); + notesButtonElement?.focus(); + }, 0); + } + + return { ...prevShowNotes, [eventId]: !prevShowNotes[eventId] }; + }); + }, []); + + const isEnterprisePlus = useLicense().isEnterprise(); + const [expandedDoc, setExpandedDoc] = useState(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + + const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; + + const toggleFullScreen = useCallback(() => { + if (timelineId === TimelineId.active) { + setTimelineFullScreen(!timelineFullScreen); + } else { + setGlobalFullScreen(!globalFullScreen); + } + }, [ + timelineId, + setTimelineFullScreen, + timelineFullScreen, + setGlobalFullScreen, + globalFullScreen, + ]); + const fullScreen = useMemo( + () => + isFullScreen({ + globalFullScreen, + isActiveTimelines: isActiveTimeline(timelineId), + timelineFullScreen, + }), + [globalFullScreen, timelineFullScreen, timelineId] + ); + + // const { activeStep, isTourShown, incrementStep } = useTourContext(); + + /* const isTourAnchor = useMemo( + () => + isTourShown(SecurityStepId.alertsCases) && + eventType === 'signal' && + isDetectionsAlertsTable(timelineId) && + ariaRowindex === 1, + [isTourShown, ariaRowindex, eventType, timelineId] + ); + const onExpandEvent = useCallback(() => { + if ( + isTourAnchor && + activeStep === AlertsCasesTourSteps.expandEvent && + isTourShown(SecurityStepId.alertsCases) + ) { + incrementStep(SecurityStepId.alertsCases); + } + onEventDetailsPanelOpened(); + }, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]);*/ + + /* const onRowSelected: OnRowSelected = useCallback( + ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { + dispatch( + timelineActions.setSelected({ + id, + eventIds: getEventIdToDataMapping(data, eventIds, queryFields), + isSelected, + isSelectAllChecked: + isSelected && Object.keys(selectedEventIds).length + 1 === data.length, + }) + ); + }, + [data, dispatch, id, queryFields, selectedEventIds] + );*/ + const discoverGridRows: Array = useMemo( + () => + events.map(({ _id, _index, ecs, data }) => { + const _source = ecs as unknown as Record; + const hit = { _id, _index: String(_index), _source }; + return { + _id, + id: _id, + data, + ecs, + raw: hit, + flattened: flattenHit(hit, dataView, { + includeIgnoredValues: true, + }), + }; + }), + [events, dataView] + ); + + const setEventsLoading = useCallback( + ({ eventIds, isLoading }) => { + dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); + }, + [dispatch, timelineId] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }) => { + dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); + }, + [dispatch, timelineId] + ); + const handleOnEventDetailPanelOpened = useCallback( + (eventData: DataTableRecord & TimelineItem) => { + const updatedExpandedDetail: ExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId: eventData.id, + indexName: eventData._index ?? '', // TODO: fix type error + refetch, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType: TimelineTabs.query, + id: timelineId, + }) + ); + + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + }, + [dispatch, refetch, timelineId] + ); + const noop = () => {}; + const leadingControlColumns = useMemo( + () => + getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ + ...x, + headerCellProps: { + ...x.headerCellProps, + columnHeaders: defaultHeaders ?? [], + timelineId: timelineId ?? 'timeline-1', + }, + rowCellRender: (cveProps: EuiDataGridCellValueElementProps) => { + return ( + onToggleShowNotes(discoverGridRows[cveProps.rowIndex])} + refetch={refetch} + setEventsLoading={setEventsLoading} + /> + ); + }, + headerCellRender: () => <>, + })), + [ + ACTION_BUTTON_COUNT, + currentTimeline.eventIdToNoteIds, + currentTimeline.loadingEventIds, + currentTimeline.selectedEventIds, + discoverGridRows, + onToggleShowNotes, + refetch, + setEventsDeleted, + setEventsLoading, + showNotes, + timelineId, + ] + ); + + // Sorting + const sortingColumns = useMemo(() => { + return ( + (sort?.map((sortingCol) => [ + sortingCol.columnId, + sortingCol.sortDirection as 'asc' | 'desc', + ]) as SortOrder[]) || [] + ); + }, [sort]); + const onSort = useCallback( + (nextSort: string[][]) => { + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: nextSort.map( + ([id, direction]) => + ({ + columnId: id, + columnType: 'keyword', + sortDirection: direction, + } as SortColumnTimeline) + ), + }) + ); + }, + [dispatch, timelineId] + ); + + const associateNote = useCallback( + (noteId: string, event: DataTableRecord) => { + dispatch(timelineActions.addNoteToEvent({ eventId: event.id, id: timelineId, noteId })); + const isEventPinned = eventIsPinned({ + eventId: event.id, + pinnedEventIds: currentTimeline.pinnedEventIds, + }); + if (!isEventPinned) { + dispatch(timelineActions.pinEvent({ id: timelineId, eventId: event.id })); + } + }, + [dispatch, currentTimeline.pinnedEventIds, timelineId] + ); + + const renderVisibleCols = useCallback( + ( + colIndex: number, + rowIndex: number, + Cell: JSXElementConstructor< + { + colIndex: number; + visibleRowIndex: number; + } & Partial + >, + visibleColumn: EuiDataGridColumn + ) => { + // Skip the row details cell - we'll render it manually outside of the flex wrapper + if (visibleColumn.id.includes('timeline')) { + return ( + + ); + } + // Render the rest of the cells normally + if (visibleColumn.id !== 'row-details') { + // TODO: using renderCellValue to render the draggable cell. We need to fix the styling though + + // return renderCellValue({ + // columnId: column.id, + // eventId: discoverGridRows[rowIndex]._id, + // data: discoverGridRows[rowIndex].data, + // header: defaultHeaders.find(({ id }) => column.id === id), + // isDraggable: true, + // isExpandable: true, + // isExpanded: false, + // isDetails: false, + // isTimeline: true, + // linkValues: undefined, + // rowIndex, + // colIndex, + // setCellProps: () => {}, + // scopeId: timelineId, + // key: `${timelineId}-query`, + // }); + return ( + + ); + } + }, + [] + ); + + const renderCustomGridBody: EuiDataGridProps['renderCustomGridBody'] = useCallback( + ({ Cell, visibleColumns: visibleCols, visibleRowData, setCustomGridBodyProps }) => { + // Ensure we're displaying correctly-paginated rows + const visibleRows = discoverGridRows.slice(visibleRowData.startRow, visibleRowData.endRow); + + // Add styling needed for custom grid body rows + const styles = { + row: css` + ${logicalCSS('width', 'fit-content')}; + ${logicalCSS('border-bottom', euiTheme.border.thin)}; + background-color: ${euiTheme.colors.emptyShade}; + `, + rowCellsWrapper: css` + display: flex; + `, + rowDetailsWrapper: css` + text-align: center; + background-color: ${euiTheme.colors.body}; + `, + }; + + // Set custom props onto the grid body wrapper + const bodyRef = useRef(null); + useEffect(() => { + setCustomGridBodyProps({ + ref: bodyRef, + }); + }, [setCustomGridBodyProps]); + + return ( + <> + {visibleRows.map((row, rowIndex) => ( +
+
+ {visibleCols.map((visibleColumn: EuiDataGridColumn, colIndex: number) => + renderVisibleCols(colIndex, rowIndex, Cell, visibleColumn) + )} +
+ {/* TODO: This renders the last row which is our expandableRow and where we can put row rendering and notes */} +
+ +
+
+ ))} + + ); + }, + [ + discoverGridRows, + euiTheme.border.thin, + euiTheme.colors.body, + euiTheme.colors.emptyShade, + renderVisibleCols, + ] + ); + + const handleOnPanelClosed = useCallback(() => { + onEventClosed({ tabType: TimelineTabs.query, id: timelineId }); + + if ( + expandedDetail[TimelineTabs.query]?.panelView && + timelineId === TimelineId.active && + showExpandedDetails + ) { + activeTimeline.toggleExpandedDetail({}); + } + }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); + + const enabledRowRenderers = useMemo(() => { + if ( + currentTimeline.excludedRowRendererIds && + currentTimeline.excludedRowRendererIds.length === Object.keys(RowRendererId).length + ) + return [plainRowRenderer]; + + if (!currentTimeline.excludedRowRendererIds) return rowRenderers; + + return rowRenderers.filter( + (rowRenderer) => !currentTimeline.excludedRowRendererIds.includes(rowRenderer.id) + ); + }, [currentTimeline.excludedRowRendererIds, rowRenderers]); + + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const notesById = useDeepEqualSelector(getNotesByIds); + const getNotes = useCallback( + (event: DataTableRecord) => { + const noteIds: string[] = currentTimeline.eventIdToNoteIds[event.id] || []; + return appSelectors.getNotes(notesById, noteIds).map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })) as unknown as TimelineResultNote[]; + }, + [currentTimeline.eventIdToNoteIds, notesById] + ); + + const containerRef = useRef(null); + + // The custom row details is actually a trailing control column cell with + // a hidden header. This is important for accessibility and markup reasons + // @see https://fuschia-stretch.glitch.me/ for more + const rowDetails: EuiDataGridProps['trailingControlColumns'] = [ + { + id: 'row-details', + + // The header cell should be visually hidden, but available to screen readers + width: 0, + headerCellRender: () => <>, + headerCellProps: { className: 'euiScreenReaderOnly' }, + + // The footer cell can be hidden to both visual & SR users, as it does not contain meaningful information + footerCellProps: { style: { display: 'none' } }, + + // When rendering this custom cell, we'll want to override + // the automatic width/heights calculated by EuiDataGrid + rowCellRender: ({ setCellProps, rowIndex }) => { + setCellProps({ style: { width: '100%', height: 'auto' } }); + return ( + <> + + + associateNote(noteId, discoverGridRows[rowIndex]) + } + data-test-subj="note-cards" + notes={getNotes(discoverGridRows[rowIndex])} + showAddNote={!!showNotes[discoverGridRows[rowIndex]._id]} + toggleShowAddNote={() => onToggleShowNotes(discoverGridRows[rowIndex])} + /> + + + + + + + + + + + ); + }, + }, + ]; + const showTimeCol = useMemo(() => !!dataView && !!dataView.timeFieldName, [dataView]); + const { onSetColumns } = useColumns({ + capabilities, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + dataView: dataView!, + dataViews, + setAppState: (newState: { columns: string[]; sort?: string[][] }) => { + if (newState.sort) { + onSort(newState.sort); + } else { + const columnsStates = newState.columns.map((columnId) => + getColumnHeader(columnId, defaultHeaders) + ); + dispatch(timelineActions.updateColumns({ id: timelineId, columns: columnsStates })); + } + }, + useNewFieldsApi: true, + columns: defaultColumns, + sort: sortingColumns, + }); + + const onColumnResize = useCallback( + ({ columnId, width }: { columnId: string; width: number }) => { + dispatch( + timelineActions.updateColumnWidth({ + columnId, + id: timelineId, + width, + }) + ); + }, + [dispatch, timelineId] + ); + + const onResizeDataGrid = useCallback( + (colSettings) => { + onColumnResize({ columnId: colSettings.columnId, width: Math.round(colSettings.width) }); + }, + [onColumnResize] + ); + const fieldBrowserOptions = useFieldBrowserOptions({ + sourcererScope: SourcererScopeName.timeline, + upsertColumn: (columnR: ColumnHeaderOptions, indexR: number) => + dispatch(timelineActions.upsertColumn({ column: columnR, id: timelineId, index: indexR })), + removeColumn: (columnId: string) => + dispatch(timelineActions.removeColumn({ columnId, id: timelineId })), + }); + + const onResetColumns = useCallback(() => { + dispatch(timelineActions.updateColumns({ id: timelineId, columns })); + }, [columns, dispatch, timelineId]); + + const onToggleColumn = useCallback( + (columnId: string) => { + if (columns.some(({ id }) => id === columnId)) { + dispatch( + timelineActions.removeColumn({ + columnId, + id: timelineId, + }) + ); + } else { + dispatch( + timelineActions.upsertColumn({ + column: getColumnHeader(columnId, defaultHeaders), + id: timelineId, + index: 1, + }) + ); + } + }, + [columns, dispatch, timelineId] + ); + const isTextBasedQuery = false; + + const onAddFilter = useCallback( + (field: DataViewField | string, values: unknown, operation: '+' | '-') => { + if (dataView && currentTimeline.filterManager) { + const fieldName = typeof field === 'string' ? field : field.name; + popularizeField(dataView, fieldName, dataViews, capabilities); + const newFilters = generateFilters( + currentTimeline.filterManager, + field, + values, + operation, + dataView + ); + return currentTimeline.filterManager.addFilters(newFilters); + } + }, + [currentTimeline.filterManager, dataView, dataViews, capabilities] + ); + const onChangeItemsPerPage = useCallback( + (itemsChangedPerPage) => + dispatch( + timelineActions.updateItemsPerPage({ id: timelineId, itemsPerPage: itemsChangedPerPage }) + ), + [dispatch, timelineId] + ); + + const onSetExpandedDoc = useCallback( + (newDoc?: DataTableRecord) => { + if (newDoc) { + const timelineDoc = discoverGridRows.find((r) => r.id === newDoc.id); + setExpandedDoc(timelineDoc); + if (timelineDoc) { + handleOnEventDetailPanelOpened(timelineDoc); + } + } else { + setExpandedDoc(undefined); + } + }, + [discoverGridRows, handleOnEventDetailPanelOpened] + ); + + const tableSettings = useMemo( + () => ({ + columns: columns.reduce((v, s) => { + if (s.initialWidth) { + v[s.id] = { width: s.initialWidth }; + } + return v; + }, {} as Record), + }), + [columns] + ); + + const renderDetailsPanel = useCallback( + () => ( + + ), + [browserFields, handleOnPanelClosed, runtimeMappings, timelineId] + ); + const additionalControls = useMemo( + () => ( + <> + {' '} + {triggersActionsUi.getFieldBrowser({ + browserFields, + columnIds: defaultColumns ?? [], + onResetColumns, + onToggleColumn, + options: fieldBrowserOptions, + })} + + <> + + + + + + ), + [ + browserFields, + defaultColumns, + fieldBrowserOptions, + fullScreen, + globalFullScreen, + onResetColumns, + onToggleColumn, + timelineFullScreen, + timelineId, + toggleFullScreen, + triggersActionsUi, + ] + ); + const customRenderers = useMemo( + () => + getFormattedFields({ + dataTableRows: discoverGridRows, + scopeId: 'timeline', + headers: columns, + }), + [columns, discoverGridRows] + ); + + const handleChangePageClick = useCallback(() => { + setFechedPage(fetchedPage + 1); + onChangePage(fetchedPage); + }, [fetchedPage, onChangePage]); + + if (!dataView) { + return null; + } + return ( + {}} + isPlainRecord={isTextBasedQuery} + rowsPerPageState={itemsPerPage} + onUpdateRowsPerPage={onChangeItemsPerPage} + onFieldEdited={() => refetch()} + cellActionsTriggerId={SecurityCellActionsTrigger.DEFAULT} + services={{ + theme, + fieldFormats, + storage, + toastNotifications: toastsService, + uiSettings, + dataViewFieldEditor, + data: dataPluginContract, + }} + visibleCellActions={3} + externalCustomRenderers={customRenderers} + renderDocumentView={renderDetailsPanel} + externalControlColumns={leadingControlColumns as unknown as EuiDataGridControlColumn[]} + externalAdditionalControls={additionalControls} + trailingControlColumns={rowDetails} + renderCustomGridBody={renderCustomGridBody} + rowsPerPageOptions={itemsPerPageOptions} + showFullScreenButton={false} + useNewFieldsApi={true} + maxDocFieldsDisplayed={50} + consumer="timeline" + totalHits={totalCount} + onFetchMoreRecords={handleChangePageClick} + configRowHeight={3} + showMultiFields={true} + /> + ); +}; + +export const TimelineDataTable = React.memo(TimelineDataTableComponent); +// eslint-disable-next-line import/no-default-export +export { TimelineDataTable as default }; 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 e6707f239f399..e75abc1d95648 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 @@ -5,16 +5,9 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiBadge, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, EuiFlyoutBody, EuiBadge } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useEffect, useCallback } from 'react'; +import React, { useMemo, useEffect, useCallback, useState } from 'react'; import styled from 'styled-components'; import type { Dispatch } from 'redux'; import type { ConnectedProps } from 'react-redux'; @@ -24,19 +17,14 @@ import { InPortal } from 'react-reverse-portal'; import { FilterManager } from '@kbn/data-plugin/public'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; -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/timeline'; -import type { CellValueElementProps } from '../cell_rendering'; -import type { Direction, TimelineItem } from '../../../../../common/search_strategy'; +import type { Direction } 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 { TimelineHeader } from '../header'; -import { calculateTotalPages } from '../helpers'; import { combineQueries } from '../../../../common/lib/kuery'; import { TimelineRefetch } from '../refetch_timeline'; import type { @@ -60,11 +48,11 @@ import { useTimelineFullScreen } from '../../../../common/containers/use_full_sc import { activeTimeline } from '../../../containers/active_timeline_context'; import { DetailsPanel } from '../../side_panel'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; -import { getDefaultControlColumn } from '../body/control_columns'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { Sourcerer } from '../../../../common/components/sourcerer'; -import { useLicense } from '../../../../common/hooks/use_license'; -import { HeaderActions } from '../../../../common/components/header_actions/header_actions'; +import { StatefulEventContext } from '../../../../common/components/events_viewer/stateful_event_context'; +import { TimelineDataTable } from '../data_table/data_table'; + const TimelineHeaderContainer = styled.div` margin-top: 6px; width: 100%; @@ -87,6 +75,7 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; flex: 1; + margin-top: 10px; .euiFlyoutBody__overflow { overflow: hidden; @@ -97,13 +86,27 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` padding: 0; height: 100%; display: flex; + flex-direction: column; + } + + .udtTimeline [data-gridcell-column-id|='select'] { + border-right: none; + } + .udtTimeline [data-gridcell-column-id|='openDetails'] .euiDataGridRowCell__contentByHeight { + margin-top: 3px; + } + + .udtTimeline [data-gridcell-column-id|='select'] .euiDataGridRowCell__contentByHeight { + margin-top: 5px; + } + + .udtTimeline .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn { + background-color: white; } -`; -const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` - background: none; - &.euiFlyoutFooter { - ${({ theme }) => `padding: ${theme.eui.euiSizeS} 0;`} + .udtTimeline .siemEventsTable__trSupplement--summary { + background-color: #f7f8fc; + border-radius: 8px; } `; @@ -147,17 +150,12 @@ const compareQueryProps = (prevProps: Props, nextProps: Props) => 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, @@ -173,7 +171,6 @@ export const QueryTabContentComponent: React.FC = ({ kqlQueryExpression, kqlQueryLanguage, onEventClosed, - renderCellValue, rowRenderers, show, showCallOutUnauthorizedMsg, @@ -184,12 +181,20 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }) => { const dispatch = useDispatch(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ + timelineID: timelineId, + enableHostDetailsFlyout: true, + enableIpDetailsFlyout: true, + tabType: activeTab, + }); + + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const { portalNode: timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); - const { setTimelineFullScreen, timelineFullScreen } = useTimelineFullScreen(); const { browserFields, dataViewId, - loading: loadingSourcerer, + loading: isSourcererLoading, indexPattern, runtimeMappings, // important to get selectedPatterns from useSourcererDataView @@ -197,9 +202,9 @@ export const QueryTabContentComponent: React.FC = ({ selectedPatterns, } = useSourcererDataView(SourcererScopeName.timeline); - const { uiSettings } = useKibana().services; - const isEnterprisePlus = useLicense().isEnterprise(); - const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; + const { + services: { uiSettings }, + } = useKibana(); const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const currentTimeline = useDeepEqualSelector((state) => @@ -240,29 +245,28 @@ export const QueryTabContentComponent: React.FC = ({ endDate: end, }); - const isBlankTimeline: boolean = - isEmpty(dataProviders) && - isEmpty(filters) && - isEmpty(kqlQuery.query) && - combinedQueries?.filterQuery === undefined; - const canQueryTimeline = useMemo( () => combinedQueries != null && - loadingSourcerer != null && - !loadingSourcerer && + isSourcererLoading != null && + !isSourcererLoading && !isEmpty(start) && !isEmpty(end) && combinedQueries?.filterQuery !== undefined, - [combinedQueries, end, loadingSourcerer, start] + [combinedQueries, end, isSourcererLoading, start] ); - const getTimelineQueryFields = () => { - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const columnFields = columnsHeader.map((c) => c.id); + const columnsHeader = useMemo(() => { + return isEmpty(columns) ? defaultHeaders : columns; + }, [columns]); - return [...columnFields, ...requiredFieldsForActions]; - }; + 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, @@ -280,22 +284,21 @@ export const QueryTabContentComponent: React.FC = ({ ); }, [dispatch, filterManager, timelineId]); - const [isQueryLoading, { events, inspect, totalCount, pageInfo, loadPage, updatedAt, refetch }] = - useTimelineEvents({ - dataViewId, - endDate: end, - fields: getTimelineQueryFields(), - filterQuery: combinedQueries?.filterQuery, - id: timelineId, - indexNames: selectedPatterns, - language: kqlQuery.language, - limit: itemsPerPage, - runtimeMappings, - skip: !canQueryTimeline, - sort: timelineQuerySortField, - startDate: start, - timerangeKind, - }); + const [isQueryLoading, { events, inspect, totalCount, refetch, loadPage }] = useTimelineEvents({ + dataViewId, + endDate: end, + fields: getTimelineQueryFields(), + filterQuery: combinedQueries?.filterQuery, + id: timelineId, + indexNames: selectedPatterns, + language: kqlQuery.language, + limit: 500, + runtimeMappings, + skip: !canQueryTimeline, + sort: timelineQuerySortField, + startDate: start, + timerangeKind, + }); const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.query, id: timelineId }); @@ -313,24 +316,15 @@ export const QueryTabContentComponent: React.FC = ({ dispatch( timelineActions.updateIsLoading({ id: timelineId, - isLoading: isQueryLoading || loadingSourcerer, + isLoading: isQueryLoading || isSourcererLoading, }) ); - }, [loadingSourcerer, timelineId, isQueryLoading, dispatch]); + }, [isSourcererLoading, timelineId, isQueryLoading, dispatch]); const isDatePickerDisabled = useMemo(() => { return (combinedQueries && combinedQueries.kqlError != null) || false; }, [combinedQueries]); - const leadingControlColumns = useMemo( - () => - getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ - ...x, - headerCellRender: HeaderActions, - })), - [ACTION_BUTTON_COUNT] - ); - return ( <> @@ -394,62 +388,45 @@ export const QueryTabContentComponent: React.FC = ({ data-test-subj={`${TimelineTabs.query}-tab-flyout-body`} className="timeline-flyout-body" > - - - - - {!isBlankTimeline && ( -