[SECURITY SOLUTION] [RAC] Event rendered view (#108644)

* wip

* match design for selecting grid view

* wip to integrate event rendered view

* wip

* integration of the event rendered

* fix perPage action on Euibasic table

* Add bulding block background color to EventRenderedView

* styling

* remove header

* fix types

* fix unit tests

* use memo for listProps

* fix styling + add feature flag

* review I

* fix merge

* change the gutter size

Co-authored-by: Pablo Neves Machado <pablo.nevesmachado@elastic.co>
Co-authored-by: Angela Chuang <yi-chun.chuang@elastic.co>
This commit is contained in:
Xavier Mouligneau 2021-08-17 17:04:35 -04:00 committed by GitHub
parent 09e8cfd305
commit 3013e10eda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 637 additions and 472 deletions

View file

@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({
metricsEntitiesEnabled: false,
ruleRegistryEnabled: false,
tGridEnabled: true,
tGridEventRenderedViewEnabled: true,
trustedAppsByPolicyEnabled: false,
excludePoliciesInFilterEnabled: false,
uebaEnabled: false,

View file

@ -67,7 +67,7 @@ export const getColumns = ({
),
sortable: false,
truncateText: false,
width: '180px',
width: '132px',
render: (values: string[] | null | undefined, data: EventFieldsData) => {
const label = data.isObjectArray
? i18n.NESTED_COLUMN(data.field)

View file

@ -58,7 +58,6 @@ export interface OwnProps {
scopeId: SourcererScopeName;
start: string;
showTotalCount?: boolean;
headerFilterGroup?: React.ReactNode;
pageFilters?: Filter[];
currentFilter?: Status;
onRuleChange?: () => void;
@ -88,7 +87,6 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
entityType,
excludedRowRendererIds,
filters,
headerFilterGroup,
id,
isLive,
itemsPerPage,
@ -120,6 +118,9 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
// TODO: Once we are past experimental phase this code should be removed
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
const tGridEventRenderedViewEnabled = useIsExperimentalFeatureEnabled(
'tGridEventRenderedViewEnabled'
);
useEffect(() => {
if (createTimeline != null) {
createTimeline({
@ -153,6 +154,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
<InspectButtonContainer>
{tGridEnabled ? (
timelinesUi.getTGrid<'embedded'>({
id,
type: 'embedded',
browserFields,
columns,
@ -165,8 +167,6 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
filters: globalFilters,
globalFullScreen,
graphOverlay,
headerFilterGroup,
id,
indexNames: selectedPatterns,
indexPattern,
isLive,
@ -186,6 +186,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
filterStatus: currentFilter,
leadingControlColumns,
trailingControlColumns,
tGridEventRenderedViewEnabled,
})
) : (
<EventsViewer
@ -198,7 +199,6 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
end={end}
isLoadingIndexPattern={isLoadingIndexPattern}
filters={globalFilters}
headerFilterGroup={headerFilterGroup}
indexNames={selectedPatterns}
indexPattern={indexPattern}
isLive={isLive}

View file

@ -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 { EuiLink } from '@elastic/eui';
import { isEmpty } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { CoreStart } from '../../../../../../src/core/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
interface RuleNameProps {
name: string;
id: string;
}
const appendSearch = (search?: string) =>
isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`;
const RuleNameComponents = ({ name, id }: RuleNameProps) => {
const { navigateToApp, getUrlForApp } = useKibana<CoreStart>().services.application;
const hrefRuleDetails = useMemo(
() =>
getUrlForApp('securitySolution', {
deepLinkId: 'rules',
path: `/id/${id}${appendSearch(window.location.search)}`,
}),
[getUrlForApp, id]
);
const goToRuleDetails = useCallback(
(ev) => {
ev.preventDefault();
navigateToApp('securitySolution', {
deepLinkId: 'rules',
path: `/id/${id}${appendSearch(window.location.search)}`,
});
},
[navigateToApp, id]
);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink href={hrefRuleDetails} onClick={goToRuleDetails}>
{name}
</EuiLink>
);
};
export const RuleName = React.memo(RuleNameComponents);

View file

@ -66,8 +66,10 @@ describe('Body', () => {
excludedRowRendererIds: [],
id: 'timeline-test',
isSelectAllChecked: false,
itemsPerPageOptions: [],
loadingEventIds: [],
loadPage: jest.fn(),
querySize: 25,
renderCellValue: TestCellRenderer,
rowRenderers: [],
selectedEventIds: {},
@ -75,6 +77,7 @@ describe('Body', () => {
sort: mockSort,
showCheckboxes: false,
tabType: TimelineTabs.query,
tableView: 'gridView',
totalPages: 1,
totalItems: 1,
leadingControlColumns: [],

View file

@ -13,6 +13,8 @@ import {
EuiDataGridStyle,
EuiDataGridToolBarVisibilityOptions,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { getOr } from 'lodash/fp';
import memoizeOne from 'memoize-one';
@ -67,6 +69,8 @@ import * as i18n from './translations';
import { AlertCount } from '../styles';
import { checkBoxControlColumn } from './control_columns';
import type { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common';
import { ViewSelection } from '../event_rendered_view/selector';
import { EventRenderedView } from '../event_rendered_view';
const StatefulAlertStatusBulkActions = lazy(
() => import('../toolbar/bulk_actions/alert_status_bulk_actions')
@ -76,25 +80,28 @@ interface OwnProps {
activePage: number;
additionalControls?: React.ReactNode;
browserFields: BrowserFields;
filterQuery: string;
bulkActions?: BulkActionsProp;
data: TimelineItem[];
defaultCellActions?: TGridCellAction[];
filterQuery: string;
filterStatus?: AlertStatus;
id: string;
indexNames: string[];
isEventViewer?: boolean;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
tabType: TimelineTabs;
itemsPerPageOptions: number[];
leadingControlColumns?: ControlColumnProps[];
loadPage: (newActivePage: number) => void;
trailingControlColumns?: ControlColumnProps[];
totalPages: number;
totalItems: number;
bulkActions?: BulkActionsProp;
filterStatus?: AlertStatus;
unit?: (total: number) => React.ReactNode;
onRuleChange?: () => void;
indexNames: string[];
querySize: number;
refetch: Refetch;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
tableView: ViewSelection;
tabType: TimelineTabs;
totalItems: number;
totalPages: number;
trailingControlColumns?: ControlColumnProps[];
unit?: (total: number) => React.ReactNode;
}
const basicUnit = (n: number) => i18n.UNIT(n);
@ -235,34 +242,37 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
activePage,
additionalControls,
browserFields,
filterQuery,
bulkActions = true,
clearSelected,
columnHeaders,
data,
defaultCellActions,
excludedRowRendererIds,
filterQuery,
filterStatus,
id,
indexNames,
isEventViewer = false,
isSelectAllChecked,
itemsPerPageOptions,
leadingControlColumns = EMPTY_CONTROL_COLUMNS,
loadingEventIds,
loadPage,
selectedEventIds,
setSelected,
clearSelected,
onRuleChange,
showCheckboxes,
querySize,
refetch,
renderCellValue,
rowRenderers,
selectedEventIds,
setSelected,
showCheckboxes,
sort,
tableView = 'gridView',
tabType,
totalPages,
totalItems,
filterStatus,
bulkActions = true,
unit = basicUnit,
leadingControlColumns = EMPTY_CONTROL_COLUMNS,
totalPages,
trailingControlColumns = EMPTY_CONTROL_COLUMNS,
indexNames,
refetch,
unit = basicUnit,
}) => {
const dispatch = useDispatch();
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
@ -336,6 +346,43 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
return bulkActions.alertStatusActions ?? true;
}, [selectedCount, showCheckboxes, bulkActions]);
const alertToolbar = useMemo(
() => (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<AlertCount>{alertCountText}</AlertCount>
</EuiFlexItem>
{showBulkActions && (
<Suspense fallback={<EuiLoadingSpinner />}>
<StatefulAlertStatusBulkActions
data-test-subj="bulk-actions"
id={id}
totalItems={totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
onActionSuccess={onAlertStatusActionSuccess}
onActionFailure={onAlertStatusActionFailure}
refetch={refetch}
/>
</Suspense>
)}
</EuiFlexGroup>
),
[
alertCountText,
filterQuery,
filterStatus,
id,
indexNames,
onAlertStatusActionFailure,
onAlertStatusActionSuccess,
refetch,
showBulkActions,
totalItems,
]
);
const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo(
() => ({
additionalControls: (
@ -573,20 +620,39 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
}, [columnHeaders, data, id, renderCellValue, tabType, theme, browserFields, rowRenderers]);
return (
<EuiDataGrid
data-test-subj="body-data-grid"
aria-label={i18n.TGRID_BODY_ARIA_LABEL}
columns={columnsWithCellActions}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={gridStyle}
leadingControlColumns={leadingTGridControlColumns}
trailingControlColumns={trailingTGridControlColumns}
toolbarVisibility={toolbarVisibility}
rowCount={data.length}
renderCellValue={renderTGridCellValue}
inMemory={{ level: 'sorting' }}
sorting={{ columns: sortingColumns, onSort }}
/>
<>
{tableView === 'gridView' && (
<EuiDataGrid
data-test-subj="body-data-grid"
aria-label={i18n.TGRID_BODY_ARIA_LABEL}
columns={columnsWithCellActions}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={gridStyle}
leadingControlColumns={leadingTGridControlColumns}
trailingControlColumns={trailingTGridControlColumns}
toolbarVisibility={toolbarVisibility}
rowCount={data.length}
renderCellValue={renderTGridCellValue}
inMemory={{ level: 'sorting' }}
sorting={{ columns: sortingColumns, onSort }}
/>
)}
{tableView === 'eventRenderedView' && (
<EventRenderedView
alertToolbar={alertToolbar}
browserFields={browserFields}
events={data}
leadingControlColumns={leadingTGridControlColumns ?? []}
onChangePage={loadPage}
pageIndex={activePage}
pageSize={querySize}
pageSizeOptions={itemsPerPageOptions}
rowRenderers={rowRenderers}
timelineId={id}
totalItemCount={totalItems}
/>
)}
</>
);
}
);
@ -635,4 +701,4 @@ const connector = connect(makeMapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
export const StatefulBody = connector(BodyComponent);
export const StatefulBody: React.FunctionComponent<OwnProps> = connector(BodyComponent);

View file

@ -0,0 +1,249 @@
/*
* Copyright 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 {
CriteriaWithPagination,
EuiBasicTable,
EuiBasicTableProps,
EuiDataGridCellValueElementProps,
EuiDataGridControlColumn,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
/* ALERT_REASON, ALERT_RULE_ID, */ ALERT_RULE_NAME,
TIMESTAMP,
} from '@kbn/rule-data-utils';
import { get } from 'lodash';
import moment from 'moment';
import React, { ComponentType, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { useUiSetting } from '../../../../../../../src/plugins/kibana_react/public';
import type { BrowserFields, RowRenderer, TimelineItem } from '../../../../common';
import { tGridActions } from '../../../store/t_grid';
import { RuleName } from '../../rule_name';
import { isEventBuildingBlockType } from '../body/helpers';
const EventRenderedFlexItem = styled(EuiFlexItem)`
div:first-child {
padding-left: 0px;
div {
margin: 0px;
}
}
`;
// Fix typing issue with EuiBasicTable and styled
type BasicTableType = ComponentType<EuiBasicTableProps<TimelineItem>>;
const StyledEuiBasicTable = styled(EuiBasicTable as BasicTableType)`
padding-top: ${({ theme }) => theme.eui.paddingSizes.m};
.EventRenderedView__buildingBlock {
background: ${({ theme }) => theme.eui.euiColorHighlight};
}
& > div:last-child {
height: 72px;
}
`;
interface EventRenderedViewProps {
alertToolbar: React.ReactNode;
browserFields: BrowserFields;
events: TimelineItem[];
leadingControlColumns: EuiDataGridControlColumn[];
onChangePage: (newActivePage: number) => void;
pageIndex: number;
pageSize: number;
pageSizeOptions: number[];
rowRenderers: RowRenderer[];
timelineId: string;
totalItemCount: number;
}
const PreferenceFormattedDateComponent = ({ value }: { value: Date }) => {
const tz = useUiSetting<string>('dateFormat:tz');
const dateFormat = useUiSetting<string>('dateFormat');
return <>{moment.tz(value, tz).format(dateFormat)}</>;
};
export const PreferenceFormattedDate = React.memo(PreferenceFormattedDateComponent);
const EventRenderedViewComponent = ({
alertToolbar,
browserFields,
events,
leadingControlColumns,
onChangePage,
pageIndex,
pageSize,
pageSizeOptions,
rowRenderers,
timelineId,
totalItemCount,
}: EventRenderedViewProps) => {
const dispatch = useDispatch();
const ActionTitle = useMemo(
() => (
<EuiFlexGroup gutterSize="m">
{leadingControlColumns.map((action) => {
const ActionHeader = action.headerCellRender;
return (
<EuiFlexItem grow={false}>
<ActionHeader />
</EuiFlexItem>
);
})}
</EuiFlexGroup>
),
[leadingControlColumns]
);
const columns = useMemo(
() => [
{
field: 'actions',
name: ActionTitle,
truncateText: false,
hideForMobile: false,
render: (name: unknown, item: unknown) => {
const alertId = get(item, '_id');
const rowIndex = events.findIndex((evt) => evt._id === alertId);
return leadingControlColumns.length > 0
? leadingControlColumns.map((action) => {
const getActions = action.rowCellRender as (
props: EuiDataGridCellValueElementProps
) => React.ReactNode;
return getActions({
columnId: 'actions',
isDetails: false,
isExpandable: false,
isExpanded: false,
rowIndex,
setCellProps: () => null,
});
})
: null;
},
},
{
field: 'ecs.@timestamp',
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.timestamp.column', {
defaultMessage: 'Timestamp',
}),
truncateText: false,
hideForMobile: false,
// eslint-disable-next-line react/display-name
render: (name: unknown, item: TimelineItem) => {
const timestamp = get(item, `ecs.${TIMESTAMP}`);
return <PreferenceFormattedDate value={timestamp} />;
},
},
{
field: `ecs.${ALERT_RULE_NAME}`,
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.rule.column', {
defaultMessage: 'Rule',
}),
truncateText: false,
hideForMobile: false,
// eslint-disable-next-line react/display-name
render: (name: unknown, item: TimelineItem) => {
const ruleName = get(item, `ecs.signal.rule.name`); /* `ecs.${ALERT_RULE_NAME}`*/
const ruleId = get(item, `ecs.signal.rule.id}`); /* `ecs.${ALERT_RULE_ID}`*/
return <RuleName name={ruleName} id={ruleId} />;
},
},
{
field: 'eventSummary',
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.eventSummary.column', {
defaultMessage: 'Event Summary',
}),
truncateText: false,
hideForMobile: false,
// eslint-disable-next-line react/display-name
render: (name: unknown, item: TimelineItem) => {
const ecsData = get(item, 'ecs');
const reason = get(item, `ecs.signal.reason`); /* `ecs.${ALERT_REASON}`*/
const rowRenderersValid = rowRenderers.filter((rowRenderer) =>
rowRenderer.isInstance(ecsData)
);
return (
<EuiFlexGroup gutterSize="none" direction="column">
{reason && <EuiFlexItem>{reason}</EuiFlexItem>}
{rowRenderersValid.length > 0 &&
rowRenderersValid.map((rowRenderer) => (
<>
<EuiHorizontalRule size="half" margin="xs" />
<EventRenderedFlexItem>
{rowRenderer.renderRow({
browserFields,
data: ecsData,
isDraggable: false,
timelineId: 'NONE',
})}
</EventRenderedFlexItem>
</>
))}
</EuiFlexGroup>
);
},
width: '60%',
},
],
[ActionTitle, browserFields, events, leadingControlColumns, rowRenderers]
);
const handleTableChange = useCallback(
(pageChange: CriteriaWithPagination<TimelineItem>) => {
if (pageChange.page.index !== pageIndex) {
onChangePage(pageChange.page.index);
}
if (pageChange.page.size !== pageSize) {
dispatch(
tGridActions.updateItemsPerPage({ id: timelineId, itemsPerPage: pageChange.page.size })
);
}
},
[dispatch, onChangePage, pageIndex, pageSize, timelineId]
);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions,
hidePerPageOptions: false,
}),
[pageIndex, pageSize, pageSizeOptions, totalItemCount]
);
return (
<>
{alertToolbar}
<StyledEuiBasicTable
compressed
items={events}
columns={columns}
pagination={pagination}
onChange={handleTableChange}
rowProps={({ ecs }: TimelineItem) =>
isEventBuildingBlockType(ecs)
? {
className: `EventRenderedView__buildingBlock`,
}
: {}
}
/>
</>
);
};
export const EventRenderedView = React.memo(EventRenderedViewComponent);

View file

@ -0,0 +1,161 @@
/*
* Copyright 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 {
EuiButtonEmpty,
EuiPopover,
EuiSelectable,
EuiSelectableOption,
EuiTitle,
EuiTextColor,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
export type ViewSelection = 'gridView' | 'eventRenderedView';
const ContainerEuiSelectable = styled.div`
width: 300px;
.euiSelectableListItem__text {
white-space: pre-wrap !important;
line-height: normal;
}
`;
const gridView = i18n.translate('xpack.timelines.alerts.summaryView.gridView.label', {
defaultMessage: 'Grid view',
});
const eventRenderedView = i18n.translate(
'xpack.timelines.alerts.summaryView.eventRendererView.label',
{
defaultMessage: 'Event rendered view',
}
);
interface SummaryViewSelectorProps {
onViewChange: (viewSelection: ViewSelection) => void;
viewSelected: ViewSelection;
}
const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryViewSelectorProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const onChangeSelectable = useCallback(
(opts: EuiSelectableOption[]) => {
const selected = opts.filter((i) => i.checked === 'on');
if (selected.length > 0) {
onViewChange((selected[0]?.key ?? 'gridView') as ViewSelection);
}
setIsPopoverOpen(false);
},
[onViewChange]
);
const button = useMemo(
() => (
<EuiButtonEmpty
iconType="arrowDown"
iconSide="right"
iconSize="s"
onClick={onButtonClick}
size="xs"
flush="both"
style={{ fontWeight: 'normal' }}
>
{viewSelected === 'gridView' ? gridView : eventRenderedView}
</EuiButtonEmpty>
),
[onButtonClick, viewSelected]
);
const options = useMemo(
() => [
{
label: gridView,
key: 'gridView',
checked: (viewSelected === 'gridView' ? 'on' : undefined) as EuiSelectableOption['checked'],
meta: [
{
text: i18n.translate('xpack.timelines.alerts.summaryView.options.default.description', {
defaultMessage:
'View as tabular data with the ability to group and sort by specific fields',
}),
},
],
},
{
label: eventRenderedView,
key: 'eventRenderedView',
checked: (viewSelected === 'eventRenderedView'
? 'on'
: undefined) as EuiSelectableOption['checked'],
meta: [
{
text: i18n.translate(
'xpack.timelines.alerts.summaryView.options.summaryView.description',
{
defaultMessage: 'View a rendering of the event flow for each alert',
}
),
},
],
},
],
[viewSelected]
);
const renderOption = useCallback((option) => {
return (
<>
<EuiTitle size="xxs">
<h6>{option.label}</h6>
</EuiTitle>
<EuiTextColor color="subdued">
<small>{option.meta[0].text}</small>
</EuiTextColor>
</>
);
}, []);
const listProps = useMemo(
() => ({
rowHeight: 80,
showIcons: true,
}),
[]
);
return (
<EuiPopover
panelPaddingSize="none"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<ContainerEuiSelectable>
<EuiSelectable
aria-label="Basic example"
options={options}
onChange={onChangeSelectable}
renderOption={renderOption}
searchable={false}
height={160}
listProps={listProps}
singleSelection={true}
>
{(list) => list}
</EuiSelectable>
</ContainerEuiSelectable>
</EuiPopover>
);
};
export const SummaryViewSelector = React.memo(SummaryViewSelectorComponent);

View file

@ -1,35 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderSection it renders 1`] = `
<Header
data-test-subj="header-section"
>
<EuiFlexGroup
alignItems="center"
>
<EuiFlexItem
grow={true}
>
<EuiFlexGroup
alignItems="center"
responsive={false}
>
<EuiFlexItem>
<EuiTitle
size="m"
>
<h2
data-test-subj="header-section-title"
>
Test title
</h2>
</EuiTitle>
<Subtitle
data-test-subj="header-section-subtitle"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</Header>
`;

View file

@ -1,159 +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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, shallow } from 'enzyme';
import React from 'react';
import { TestProviders } from '../../../mock';
import { HeaderSection } from './index';
describe('HeaderSection', () => {
test('it renders', () => {
const wrapper = shallow(<HeaderSection title="Test title" inspect={null} loading={false} />);
expect(wrapper).toMatchSnapshot();
});
test('it renders the title', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-section-title"]').first().exists()).toBe(true);
});
test('it renders the subtitle when provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection subtitle="Test subtitle" title="Test title" inspect={null} loading={false} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true);
});
test('renders the subtitle when not provided (to prevent layout thrash)', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true);
});
test('it renders supplements when children provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false}>
<p>{'Test children'}</p>
</HeaderSection>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe(
true
);
});
test('it DOES NOT render supplements when children not provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe(
false
);
});
test('it applies border styles when border is true', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection border title="Test title" inspect={null} loading={false} />
</TestProviders>
);
const siemHeaderSection = wrapper.find('.siemHeaderSection').first();
expect(siemHeaderSection).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin);
expect(siemHeaderSection).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l);
});
test('it DOES NOT apply border styles when border is false', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false} />
</TestProviders>
);
const siemHeaderSection = wrapper.find('.siemHeaderSection').first();
expect(siemHeaderSection).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin);
expect(siemHeaderSection).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l);
});
test('it splits the title and supplement areas evenly when split is true', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection split title="Test title" inspect={null} loading={false}>
<p>{'Test children'}</p>
</HeaderSection>
</TestProviders>
);
expect(
wrapper
.find('.euiFlexItem--flexGrowZero[data-test-subj="header-section-supplements"]')
.first()
.exists()
).toBe(false);
});
test('it DOES NOT split the title and supplement areas evenly when split is false', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false}>
<p>{'Test children'}</p>
</HeaderSection>
</TestProviders>
);
expect(
wrapper
.find('.euiFlexItem--flexGrowZero[data-test-subj="header-section-supplements"]')
.first()
.exists()
).toBe(true);
});
test('it renders an inspect button when an `id` is provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection id="an id" title="Test title" inspect={null} loading={false}>
<p>{'Test children'}</p>
</HeaderSection>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true);
});
test('it does NOT an inspect button when an `id` is NOT provided', () => {
const wrapper = mount(
<TestProviders>
<HeaderSection title="Test title" inspect={null} loading={false}>
<p>{'Test children'}</p>
</HeaderSection>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false);
});
});

View file

@ -1,106 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui';
import React from 'react';
import styled, { css } from 'styled-components';
import { InspectQuery } from '../../../store/t_grid/inputs';
import { InspectButton } from '../../inspect';
import { Subtitle } from '../subtitle';
interface HeaderProps {
border?: boolean;
height?: number;
}
const Header = styled.header.attrs(() => ({
className: 'siemHeaderSection',
}))<HeaderProps>`
${({ height }) =>
height &&
css`
height: ${height}px;
`}
margin-bottom: ${({ height, theme }) => (height ? 0 : theme.eui.euiSizeL)};
user-select: text;
${({ border }) =>
border &&
css`
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
padding-bottom: ${({ theme }) => theme.eui.paddingSizes.l};
`}
`;
Header.displayName = 'Header';
export interface HeaderSectionProps extends HeaderProps {
children?: React.ReactNode;
height?: number;
id?: string;
inspect: InspectQuery | null;
loading: boolean;
split?: boolean;
subtitle?: string | React.ReactNode;
title: string | React.ReactNode;
titleSize?: EuiTitleSize;
tooltip?: string;
growLeftSplit?: boolean;
}
const HeaderSectionComponent: React.FC<HeaderSectionProps> = ({
border,
children,
height,
id,
inspect,
loading,
split,
subtitle,
title,
titleSize = 'm',
tooltip,
growLeftSplit = true,
}) => (
<Header data-test-subj="header-section" border={border} height={height}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={growLeftSplit}>
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem>
<EuiTitle size={titleSize}>
<h2 data-test-subj="header-section-title">
{title}
{tooltip && (
<>
{' '}
<EuiIconTip color="subdued" content={tooltip} size="l" type="iInCircle" />
</>
)}
</h2>
</EuiTitle>
<Subtitle data-test-subj="header-section-subtitle" items={subtitle} />
</EuiFlexItem>
{id && (
<EuiFlexItem grow={false}>
<InspectButton title={title} inspect={inspect} loading={loading} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{children && (
<EuiFlexItem data-test-subj="header-section-supplements" grow={split ? true : false}>
{children}
</EuiFlexItem>
)}
</EuiFlexGroup>
</Header>
);
export const HeaderSection = React.memo(HeaderSectionComponent);

View file

@ -47,19 +47,17 @@ import {
} from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import { useTimelineEvents } from '../../../container';
import { HeaderSection } from '../header_section';
import { StatefulBody } from '../body';
import { Footer, footerHeight } from '../footer';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem } from '../styles';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexGroup, UpdatedFlexItem } from '../styles';
import * as i18n from '../translations';
import { ExitFullScreen } from '../../exit_full_screen';
import { Sort } from '../body/sort';
import { InspectButtonContainer } from '../../inspect';
import { InspectButton, InspectButtonContainer } from '../../inspect';
import { SummaryViewSelector, ViewSelection } from '../event_rendered_view/selector';
const AlertConsumers: typeof AlertConsumersTyped = AlertConsumersNonTyped;
export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px
const COMPACT_HEADER_HEIGHT = 36; // px
const TitleText = styled.span`
margin-right: 12px;
@ -80,13 +78,10 @@ const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>`
`}
`;
const TitleFlexGroup = styled(EuiFlexGroup)`
margin-top: 8px;
`;
const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({
className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`,
}))`
position: relative;
width: 100%;
overflow: hidden;
flex: 1;
@ -104,14 +99,6 @@ const ScrollableFlexItem = styled(EuiFlexItem)`
overflow: auto;
`;
/**
* Hides stateful headerFilterGroup implementations, but prevents the component
* from being unmounted, to preserve the state of the component
*/
const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>`
${({ show }) => (show ? '' : 'visibility: hidden;')}
`;
const SECURITY_ALERTS_CONSUMERS = [AlertConsumers.SIEM];
export interface TGridIntegratedProps {
@ -126,7 +113,6 @@ export interface TGridIntegratedProps {
filters: Filter[];
globalFullScreen: boolean;
graphOverlay?: React.ReactNode;
headerFilterGroup?: React.ReactNode;
filterStatus?: AlertStatus;
height?: number;
id: TimelineId;
@ -150,6 +136,7 @@ export interface TGridIntegratedProps {
leadingControlColumns?: ControlColumnProps[];
trailingControlColumns?: ControlColumnProps[];
data?: DataPublicPluginStart;
tGridEventRenderedViewEnabled: boolean;
}
const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
@ -163,7 +150,6 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
entityType,
filters,
globalFullScreen,
headerFilterGroup,
filterStatus,
id,
indexNames,
@ -185,6 +171,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
graphEventId,
leadingControlColumns,
trailingControlColumns,
tGridEventRenderedViewEnabled,
data,
}) => {
const dispatch = useDispatch();
@ -192,6 +179,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
const { uiSettings } = useKibana<CoreStart>().services;
const [isQueryLoading, setIsQueryLoading] = useState(false);
const [tableView, setTableView] = useState<ViewSelection>('gridView');
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const unit = useMemo(() => (n: number) => i18n.ALERTS_UNIT(n), []);
const { queryFields, title } = useDeepEqualSelector((state) =>
@ -203,17 +191,6 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
}, [dispatch, id, isQueryLoading]);
const justTitle = useMemo(() => <TitleText data-test-subj="title">{title}</TitleText>, [title]);
const titleWithExitFullScreen = useMemo(
() => (
<TitleFlexGroup alignItems="center" data-test-subj="title-flex-group" gutterSize="none">
<EuiFlexItem grow={false}>{justTitle}</EuiFlexItem>
<EuiFlexItem grow={false}>
<ExitFullScreen fullScreen={globalFullScreen} setFullScreen={setGlobalFullScreen} />
</EuiFlexItem>
</TitleFlexGroup>
),
[globalFullScreen, justTitle, setGlobalFullScreen]
);
const combinedQueries = buildCombinedQuery({
config: esQuery.getEsQueryConfig(uiSettings),
@ -295,23 +272,12 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
events,
]);
const HeaderSectionContent = useMemo(
() =>
headerFilterGroup && (
<HeaderFilterGroupWrapper
data-test-subj="header-filter-group-wrapper"
show={!resolverIsShowing(graphEventId)}
>
{headerFilterGroup}
</HeaderFilterGroupWrapper>
),
[headerFilterGroup, graphEventId]
);
useEffect(() => {
setIsQueryLoading(loading);
}, [loading]);
const alignItems = tableView === 'gridView' ? 'baseline' : 'center';
return (
<InspectButtonContainer>
<StyledEuiPanel
@ -325,27 +291,23 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
{canQueryTimeline ? (
<>
<HeaderSection
id={!resolverIsShowing(graphEventId) ? id : undefined}
inspect={inspect}
loading={loading}
height={
headerFilterGroup == null ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT
}
title={globalFullScreen ? titleWithExitFullScreen : justTitle}
>
{HeaderSectionContent}
</HeaderSection>
<EventsContainerLoading
data-timeline-id={id}
data-test-subj={`events-container-loading-${loading}`}
>
{graphOverlay}
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<UpdatedFlexGroup gutterSize="m" justifyContent="flexEnd" alignItems={alignItems}>
<UpdatedFlexItem grow={false} show={!loading}>
<InspectButton title={justTitle} inspect={inspect} loading={loading} />
</UpdatedFlexItem>
<UpdatedFlexItem grow={false} show={!loading}>
{!resolverIsShowing(graphEventId) && additionalFilters}
</UpdatedFlexItem>
</EuiFlexGroup>
{tGridEventRenderedViewEnabled && (
<UpdatedFlexItem grow={false} show={!loading}>
<SummaryViewSelector viewSelected={tableView} onViewChange={setTableView} />
</UpdatedFlexItem>
)}
</UpdatedFlexGroup>
<FullWidthFlexGroup
$visible={!graphEventId && graphOverlay == null}
@ -382,11 +344,14 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
defaultCellActions={defaultCellActions}
id={id}
isEventViewer={true}
itemsPerPageOptions={itemsPerPageOptions}
loadPage={loadPage}
onRuleChange={onRuleChange}
querySize={pageInfo.querySize}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
tabType={TimelineTabs.query}
tableView={tableView}
totalPages={calculateTotalPages({
itemsCount: totalCountMinusDeleted,
itemsPerPage,
@ -399,19 +364,21 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
refetch={refetch}
indexNames={indexNames}
/>
<Footer
activePage={pageInfo.activePage}
data-test-subj="events-viewer-footer"
height={footerHeight}
id={id}
isLive={isLive}
isLoading={loading}
itemsCount={nonDeletedEvents.length}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
onChangePage={loadPage}
totalCount={totalCountMinusDeleted}
/>
{tableView === 'gridView' && (
<Footer
activePage={pageInfo.activePage}
data-test-subj="events-viewer-footer"
height={footerHeight}
id={id}
isLive={isLive}
isLoading={loading}
itemsCount={nonDeletedEvents.length}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
onChangePage={loadPage}
totalCount={totalCountMinusDeleted}
/>
)}
</>
)}
</ScrollableFlexItem>

View file

@ -32,27 +32,20 @@ import {
} from '../../../../../../../src/plugins/data/public';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { defaultHeaders } from '../body/column_headers/default_headers';
import {
calculateTotalPages,
combineQueries,
getCombinedFilterQuery,
resolverIsShowing,
} from '../helpers';
import { calculateTotalPages, combineQueries, getCombinedFilterQuery } from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import type { State } from '../../../store/t_grid';
import { useTimelineEvents } from '../../../container';
import { HeaderSection } from '../header_section';
import { StatefulBody } from '../body';
import { Footer, footerHeight } from '../footer';
import { LastUpdatedAt } from '../..';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem } from '../styles';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem, UpdatedFlexGroup } from '../styles';
import * as i18n from '../translations';
import { InspectButtonContainer } from '../../inspect';
import { InspectButton, InspectButtonContainer } from '../../inspect';
import { useFetchIndex } from '../../../container/source';
import { AddToCaseAction } from '../../actions/timeline/cases/add_to_case_action';
export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px
const COMPACT_HEADER_HEIGHT = 36; // px
const STANDALONE_ID = 'standalone-t-grid';
const EMPTY_DATA_PROVIDERS: DataProvider[] = [];
@ -68,6 +61,7 @@ const AlertsTableWrapper = styled.div`
const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({
className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`,
}))`
position: relative;
width: 100%;
overflow: hidden;
flex: 1;
@ -86,14 +80,6 @@ const ScrollableFlexItem = styled(EuiFlexItem)`
overflow: auto;
`;
/**
* Hides stateful headerFilterGroup implementations, but prevents the component
* from being unmounted, to preserve the state of the component
*/
const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>`
${({ show }) => (show ? '' : 'visibility: hidden;')}
`;
export interface TGridStandaloneProps {
alertConsumers: AlertConsumers[];
appId: string;
@ -110,7 +96,6 @@ export interface TGridStandaloneProps {
loadingText: React.ReactNode;
filters: Filter[];
footerText: React.ReactNode;
headerFilterGroup?: React.ReactNode;
filterStatus: AlertStatus;
height?: number;
indexNames: string[];
@ -145,7 +130,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
loadingText,
filters,
footerText,
headerFilterGroup,
filterStatus,
indexNames,
itemsPerPage,
@ -273,19 +257,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
events,
]);
const HeaderSectionContent = useMemo(
() =>
headerFilterGroup && (
<HeaderFilterGroupWrapper
data-test-subj="header-filter-group-wrapper"
show={!resolverIsShowing(graphEventId)}
>
{headerFilterGroup}
</HeaderFilterGroupWrapper>
),
[headerFilterGroup, graphEventId]
);
const filterQuery = useMemo(
() =>
getCombinedFilterQuery({
@ -339,27 +310,18 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
<AlertsTableWrapper>
{canQueryTimeline ? (
<>
<HeaderSection
id={!resolverIsShowing(graphEventId) ? STANDALONE_ID : undefined}
inspect={inspect}
loading={loading}
height={
headerFilterGroup == null ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT
}
title={justTitle}
>
{HeaderSectionContent}
</HeaderSection>
<EventsContainerLoading
data-timeline-id={STANDALONE_ID}
data-test-subj={`events-container-loading-${loading}`}
>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<UpdatedFlexGroup gutterSize="s" justifyContent="flexEnd" alignItems="baseline">
<UpdatedFlexItem grow={false} show={!loading}>
<InspectButton title={justTitle} inspect={inspect} loading={loading} />
</UpdatedFlexItem>
<UpdatedFlexItem grow={false} show={!loading}>
<LastUpdatedAt updatedAt={updatedAt} />
</UpdatedFlexItem>
</EuiFlexGroup>
</UpdatedFlexGroup>
<FullWidthFlexGroup direction="row" $visible={!graphEventId} gutterSize="none">
<ScrollableFlexItem grow={1}>
@ -368,25 +330,28 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
browserFields={browserFields}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
filterQuery={filterQuery}
id={STANDALONE_ID}
indexNames={indexNames}
isEventViewer={true}
itemsPerPageOptions={itemsPerPageOptionsStore}
leadingControlColumns={leadingControlColumns}
loadPage={loadPage}
onRuleChange={onRuleChange}
refetch={refetch}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
onRuleChange={onRuleChange}
querySize={pageInfo.querySize}
tabType={TimelineTabs.query}
tableView="gridView"
totalPages={calculateTotalPages({
itemsCount: totalCountMinusDeleted,
itemsPerPage: itemsPerPageStore,
})}
totalItems={totalCountMinusDeleted}
indexNames={indexNames}
filterQuery={filterQuery}
unit={unit}
filterStatus={filterStatus}
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
refetch={refetch}
/>
<Footer
activePage={pageInfo.activePage}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import { rgba } from 'polished';
import styled, { createGlobalStyle } from 'styled-components';
@ -459,12 +459,14 @@ export const HideShowContainer = styled.div.attrs<{ $isVisible: boolean }>(
})
)<{ $isVisible: boolean }>``;
export const UpdatedFlexItem = styled(EuiFlexItem)<{ show: boolean }>`
height: 0px;
position: relative;
top: ${({ theme }) => theme.eui.paddingSizes.s};
${({ show }) => (show ? '' : 'visibility: hidden;')}
export const UpdatedFlexGroup = styled(EuiFlexGroup)`
position: absolute;
z-index: ${({ theme }) => theme.eui.euiZLevel1};
right: 0px;
`;
export const UpdatedFlexItem = styled(EuiFlexItem)<{ show: boolean }>`
${({ show }) => (show ? '' : 'visibility: hidden;')}
`;
export const AlertCount = styled.span`