mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[SecuritySolution] Add alert prevalence column to highlighted fields table (#127599)
Co-authored-by: Jan Monschke <jan.monschke@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
067f4c2ede
commit
26a47d069f
27 changed files with 919 additions and 167 deletions
|
@ -5,56 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
|
||||
import { SummaryView } from './summary_view';
|
||||
import { AlertSummaryRow, getSummaryColumns, SummaryRow } from './helpers';
|
||||
|
||||
import { ActionCell } from './table/action_cell';
|
||||
import { FieldValueCell } from './table/field_value_cell';
|
||||
import { TimelineId } from '../../../../common/types';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
|
||||
import { getSummaryRows } from './get_alert_summary_rows';
|
||||
|
||||
const getDescription = ({
|
||||
data,
|
||||
eventId,
|
||||
fieldFromBrowserField,
|
||||
isDraggable,
|
||||
linkValue,
|
||||
timelineId,
|
||||
values,
|
||||
}: AlertSummaryRow['description']) => (
|
||||
<>
|
||||
<FieldValueCell
|
||||
contextId={timelineId}
|
||||
data={data}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={fieldFromBrowserField}
|
||||
linkValue={linkValue}
|
||||
isDraggable={isDraggable}
|
||||
values={values}
|
||||
/>
|
||||
{timelineId !== TimelineId.active && (
|
||||
<ActionCell
|
||||
contextId={timelineId}
|
||||
data={data}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={fieldFromBrowserField}
|
||||
linkValue={linkValue}
|
||||
timelineId={timelineId}
|
||||
values={values}
|
||||
applyWidthAndPadding={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(getDescription);
|
||||
|
||||
const AlertSummaryViewComponent: React.FC<{
|
||||
browserFields: BrowserFields;
|
||||
data: TimelineEventsDetailsItem[];
|
||||
|
@ -69,14 +28,7 @@ const AlertSummaryViewComponent: React.FC<{
|
|||
[browserFields, data, eventId, isDraggable, timelineId]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryView
|
||||
summaryColumns={summaryColumns}
|
||||
summaryRows={summaryRows}
|
||||
title={title}
|
||||
goToTable={goToTable}
|
||||
/>
|
||||
);
|
||||
return <SummaryView rows={summaryRows} title={title} goToTable={goToTable} />;
|
||||
};
|
||||
|
||||
export const AlertSummaryView = React.memo(AlertSummaryViewComponent);
|
||||
|
|
|
@ -17,14 +17,19 @@ import {
|
|||
} from '@elastic/eui';
|
||||
|
||||
import { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
|
||||
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment, getFirstSeen } from './helpers';
|
||||
import {
|
||||
getEnrichmentIdentifiers,
|
||||
isInvestigationTimeEnrichment,
|
||||
getFirstSeen,
|
||||
ThreatDetailsRow,
|
||||
} from './helpers';
|
||||
import { EnrichmentButtonContent } from './enrichment_button_content';
|
||||
import { ThreatSummaryTitle } from './threat_summary_title';
|
||||
import { InspectButton } from '../../inspect';
|
||||
import { QUERY_ID } from '../../../containers/cti/event_enrichment';
|
||||
import * as i18n from './translations';
|
||||
import { StyledEuiInMemoryTable } from '../summary_view';
|
||||
import { ThreatSummaryTable } from './threat_summary_table';
|
||||
import { REFERENCE } from '../../../../../common/cti/constants';
|
||||
import { getSummaryColumns, SummaryRow, ThreatDetailsRow } from '../helpers';
|
||||
import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants';
|
||||
import { getFirstElement } from '../../../../../common/utils/data_retrieval';
|
||||
|
||||
|
@ -65,7 +70,21 @@ const ThreatDetailsDescription: React.FC<ThreatDetailsRow['description']> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<SummaryRow>> = getSummaryColumns(ThreatDetailsDescription);
|
||||
const columns: Array<EuiBasicTableColumn<ThreatDetailsRow>> = [
|
||||
{
|
||||
field: 'title',
|
||||
truncateText: false,
|
||||
render: ThreatSummaryTitle,
|
||||
width: '220px',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
truncateText: false,
|
||||
render: ThreatDetailsDescription,
|
||||
name: '',
|
||||
},
|
||||
];
|
||||
|
||||
const buildThreatDetailsItems = (enrichment: CtiEnrichment) =>
|
||||
Object.keys(enrichment)
|
||||
|
@ -107,7 +126,7 @@ const EnrichmentAccordion: React.FC<{
|
|||
)
|
||||
}
|
||||
>
|
||||
<StyledEuiInMemoryTable
|
||||
<ThreatSummaryTable
|
||||
columns={columns}
|
||||
compressed
|
||||
data-test-subj={`threat-details-view-${index}`}
|
||||
|
|
|
@ -126,3 +126,11 @@ export const getFirstSeen = (enrichment: CtiEnrichment): number => {
|
|||
const firstSeenDate = Date.parse(firstSeenValue ?? 'no date');
|
||||
return Number.isInteger(firstSeenDate) ? firstSeenDate : new Date(-1).valueOf();
|
||||
};
|
||||
|
||||
export interface ThreatDetailsRow {
|
||||
title: string;
|
||||
description: {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { EuiInMemoryTable } from '@elastic/eui';
|
||||
|
||||
export const ThreatSummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
|
||||
.euiTableHeaderCell,
|
||||
.euiTableRowCell {
|
||||
border: none;
|
||||
}
|
||||
.euiTableHeaderCell .euiTableCellContent {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 styled from 'styled-components';
|
||||
import React from 'react';
|
||||
import { EuiTitle } from '@elastic/eui';
|
||||
|
||||
const StyledH5 = styled.h5`
|
||||
line-height: 1.7rem;
|
||||
`;
|
||||
|
||||
export const ThreatSummaryTitle = (title: string) => (
|
||||
<EuiTitle size="xxxs">
|
||||
<StyledH5>{title}</StyledH5>
|
||||
</EuiTitle>
|
||||
);
|
||||
ThreatSummaryTitle.displayName = 'ThreatSummaryTitle';
|
|
@ -178,7 +178,7 @@ const EventDetailsComponent: React.FC<Props> = ({
|
|||
browserFields,
|
||||
isDraggable,
|
||||
timelineId,
|
||||
title: i18n.HIGHLIGHTES_FIELDS,
|
||||
title: i18n.HIGHLIGHTED_FIELDS,
|
||||
}}
|
||||
goToTable={goToTableTab}
|
||||
/>
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from '../../../detections/components/alerts_table/translations';
|
||||
import { ALERT_THRESHOLD_RESULT } from '../../../../common/field_maps/field_names';
|
||||
import { AGENT_STATUS_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants';
|
||||
import { getEnrichedFieldInfo, SummaryRow } from './helpers';
|
||||
import { getEnrichedFieldInfo, AlertSummaryRow } from './helpers';
|
||||
import { EventSummaryField, EnrichedFieldInfo } from './types';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
|
||||
|
||||
|
@ -253,7 +253,7 @@ export const getSummaryRows = ({
|
|||
});
|
||||
|
||||
return data != null
|
||||
? tableFields.reduce<SummaryRow[]>((acc, field) => {
|
||||
? tableFields.reduce<AlertSummaryRow[]>((acc, field) => {
|
||||
const item = data.find(
|
||||
(d) => d.field === field.id || (field.legacyId && d.field === field.legacyId)
|
||||
);
|
||||
|
|
|
@ -7,9 +7,6 @@
|
|||
|
||||
import { get, getOr, isEmpty, uniqBy } from 'lodash/fp';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import React from 'react';
|
||||
import { EuiBasicTableColumn, EuiTitle } from '@elastic/eui';
|
||||
import {
|
||||
elementOrChildrenHasFocus,
|
||||
getFocusedDataColindexCell,
|
||||
|
@ -62,16 +59,6 @@ export interface AlertSummaryRow {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ThreatDetailsRow {
|
||||
title: string;
|
||||
description: {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SummaryRow = AlertSummaryRow | ThreatDetailsRow;
|
||||
|
||||
export const getColumnHeaderFromBrowserField = ({
|
||||
browserField,
|
||||
width = DEFAULT_COLUMN_MIN_WIDTH,
|
||||
|
@ -194,40 +181,6 @@ export const onEventDetailsTabKeyPressed = ({
|
|||
}
|
||||
};
|
||||
|
||||
const StyledH5 = styled.h5`
|
||||
line-height: 1.7rem;
|
||||
`;
|
||||
|
||||
const getTitle = (title: string) => (
|
||||
<EuiTitle size="xxxs">
|
||||
<StyledH5>{title}</StyledH5>
|
||||
</EuiTitle>
|
||||
);
|
||||
getTitle.displayName = 'getTitle';
|
||||
|
||||
export const getSummaryColumns = (
|
||||
DescriptionComponent:
|
||||
| React.FC<AlertSummaryRow['description']>
|
||||
| React.FC<ThreatDetailsRow['description']>
|
||||
): Array<EuiBasicTableColumn<SummaryRow>> => {
|
||||
return [
|
||||
{
|
||||
field: 'title',
|
||||
truncateText: false,
|
||||
render: getTitle,
|
||||
width: '220px',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
className: 'flyoutOverviewDescription',
|
||||
field: 'description',
|
||||
truncateText: false,
|
||||
render: DescriptionComponent,
|
||||
name: '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function getEnrichedFieldInfo({
|
||||
browserFields,
|
||||
contextId,
|
||||
|
|
|
@ -153,12 +153,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
|||
<div
|
||||
data-test-subj="hover-actions-filter-for"
|
||||
>
|
||||
Filter button
|
||||
<span
|
||||
data-test-subj="test-filter-for"
|
||||
>
|
||||
Filter button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="hover-actions-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
<span
|
||||
data-test-subj="test-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="more-actions-kibana.alert.workflow_status"
|
||||
|
@ -214,12 +222,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
|||
<div
|
||||
data-test-subj="hover-actions-filter-for"
|
||||
>
|
||||
Filter button
|
||||
<span
|
||||
data-test-subj="test-filter-for"
|
||||
>
|
||||
Filter button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="hover-actions-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
<span
|
||||
data-test-subj="test-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="more-actions-kibana.alert.risk_score"
|
||||
|
@ -287,12 +303,20 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
|||
<div
|
||||
data-test-subj="hover-actions-filter-for"
|
||||
>
|
||||
Filter button
|
||||
<span
|
||||
data-test-subj="test-filter-for"
|
||||
>
|
||||
Filter button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="hover-actions-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
<span
|
||||
data-test-subj="test-filter-out"
|
||||
>
|
||||
Filter out button
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-test-subj="more-actions-kibana.alert.rule.name"
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BrowserField } from '../../containers/source';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { EventFieldsData } from './types';
|
||||
import { SummaryView } from './summary_view';
|
||||
import { TimelineId } from '../../../../common/types';
|
||||
import { AlertSummaryRow } from './helpers';
|
||||
|
||||
jest.mock('../../lib/kibana');
|
||||
|
||||
const eventId = 'TUWyf3wBFCFU0qRJTauW';
|
||||
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
|
||||
const hostIpFieldFromBrowserField: BrowserField = {
|
||||
aggregatable: true,
|
||||
category: 'host',
|
||||
description: 'Host ip addresses.',
|
||||
example: '127.0.0.1',
|
||||
fields: {},
|
||||
format: '',
|
||||
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
|
||||
name: 'host.ip',
|
||||
readFromDocValues: false,
|
||||
searchable: true,
|
||||
type: 'ip',
|
||||
};
|
||||
const hostIpData: EventFieldsData = {
|
||||
...hostIpFieldFromBrowserField,
|
||||
ariaRowindex: 35,
|
||||
field: 'host.ip',
|
||||
fields: {},
|
||||
format: '',
|
||||
isObjectArray: false,
|
||||
originalValue: [...hostIpValues],
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
const enrichedHostIpData: AlertSummaryRow['description'] = {
|
||||
data: { ...hostIpData },
|
||||
eventId,
|
||||
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
|
||||
isDraggable: false,
|
||||
timelineId: TimelineId.test,
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
jest.mock('../../containers/alerts/use_alert_prevalence', () => ({
|
||||
useAlertPrevalence: () => ({
|
||||
loading: false,
|
||||
count: 1,
|
||||
error: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Summary View', () => {
|
||||
describe('when no data is provided', () => {
|
||||
test('should show an empty table', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<SummaryView goToTable={jest.fn()} title="Test Summary View" rows={[]} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByText('No items found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when data is provided', () => {
|
||||
test('should show the data', () => {
|
||||
const sampleRows: AlertSummaryRow[] = [
|
||||
{
|
||||
title: hostIpData.field,
|
||||
description: enrichedHostIpData,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
<SummaryView goToTable={jest.fn()} title="Test Summary View" rows={sampleRows} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByText(hostIpData.field)).toBeInTheDocument();
|
||||
hostIpValues.forEach((ipValue) => {
|
||||
expect(screen.getByText(ipValue)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiInMemoryTable,
|
||||
EuiBasicTableColumn,
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
|
@ -14,51 +13,60 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiIconTip,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { SummaryRow } from './helpers';
|
||||
import type { AlertSummaryRow } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
import { VIEW_ALL_FIELDS } from './translations';
|
||||
import { SummaryTable } from './table/summary_table';
|
||||
import { SummaryValueCell } from './table/summary_value_cell';
|
||||
import { PrevalenceCellRenderer } from './table/prevalence_cell';
|
||||
|
||||
export const Indent = styled.div`
|
||||
padding: 0 12px;
|
||||
`;
|
||||
const summaryColumns: Array<EuiBasicTableColumn<AlertSummaryRow>> = [
|
||||
{
|
||||
field: 'title',
|
||||
truncateText: false,
|
||||
name: i18n.HIGHLIGHTED_FIELDS_FIELD,
|
||||
textOnly: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
truncateText: false,
|
||||
render: SummaryValueCell,
|
||||
name: i18n.HIGHLIGHTED_FIELDS_VALUE,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
truncateText: true,
|
||||
render: PrevalenceCellRenderer,
|
||||
name: (
|
||||
<>
|
||||
{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE}{' '}
|
||||
<EuiIconTip
|
||||
type="iInCircle"
|
||||
color="subdued"
|
||||
title="Alert Prevalence"
|
||||
content={<span>{i18n.HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP}</span>}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
align: 'right',
|
||||
width: '130px',
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
|
||||
.euiTableHeaderCell,
|
||||
.euiTableRowCell {
|
||||
border: none;
|
||||
}
|
||||
.euiTableHeaderCell .euiTableCellContent {
|
||||
padding: 0;
|
||||
}
|
||||
const rowProps = {
|
||||
// Class name for each row. On hover of a row, all actions for that row will be shown.
|
||||
className: 'flyoutTableHoverActions',
|
||||
};
|
||||
|
||||
.flyoutOverviewDescription {
|
||||
.hoverActions-active {
|
||||
.timelines__hoverActionButton,
|
||||
.securitySolution__hoverActionButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.timelines__hoverActionButton,
|
||||
.securitySolution__hoverActionButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SummaryViewComponent: React.FC<{
|
||||
const SummaryViewComponent: React.FC<{
|
||||
goToTable: () => void;
|
||||
title: string;
|
||||
summaryColumns: Array<EuiBasicTableColumn<SummaryRow>>;
|
||||
summaryRows: SummaryRow[];
|
||||
dataTestSubj?: string;
|
||||
}> = ({ goToTable, summaryColumns, summaryRows, dataTestSubj = 'summary-view', title }) => {
|
||||
rows: AlertSummaryRow[];
|
||||
}> = ({ goToTable, rows, title }) => {
|
||||
return (
|
||||
<div>
|
||||
<EuiFlexGroup>
|
||||
|
@ -74,14 +82,13 @@ export const SummaryViewComponent: React.FC<{
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<Indent>
|
||||
<StyledEuiInMemoryTable
|
||||
data-test-subj={dataTestSubj}
|
||||
items={summaryRows}
|
||||
columns={summaryColumns}
|
||||
compressed
|
||||
/>
|
||||
</Indent>
|
||||
<SummaryTable
|
||||
data-test-subj="summary-view"
|
||||
items={rows}
|
||||
columns={summaryColumns}
|
||||
rowProps={rowProps}
|
||||
compressed
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ interface Props extends EnrichedFieldInfo {
|
|||
getLinkValue?: (field: string) => string | null;
|
||||
onFilterAdded?: () => void;
|
||||
toggleColumn?: (column: ColumnHeaderOptions) => void;
|
||||
hideAddToTimeline?: boolean;
|
||||
}
|
||||
|
||||
export const ActionCell: React.FC<Props> = React.memo(
|
||||
|
@ -34,6 +35,7 @@ export const ActionCell: React.FC<Props> = React.memo(
|
|||
timelineId,
|
||||
toggleColumn,
|
||||
values,
|
||||
hideAddToTimeline,
|
||||
}) => {
|
||||
const actionCellConfig = useActionCellDataProvider({
|
||||
contextId,
|
||||
|
@ -69,6 +71,7 @@ export const ActionCell: React.FC<Props> = React.memo(
|
|||
dataProvider={actionCellConfig?.dataProvider}
|
||||
enableOverflowButton={true}
|
||||
field={data.field}
|
||||
hideAddToTimeline={hideAddToTimeline}
|
||||
isObjectArray={data.isObjectArray}
|
||||
onFilterAdded={onFilterAdded}
|
||||
ownFocus={hoverActionsOwnFocus}
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BrowserField } from '../../../containers/source';
|
||||
import { AddToTimelineCellRenderer } from './add_to_timeline_cell';
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { EventFieldsData } from '../types';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
|
||||
jest.mock('../../../lib/kibana');
|
||||
|
||||
const eventId = 'TUWyf3wBFCFU0qRJTauW';
|
||||
|
||||
const hostIpFieldFromBrowserField: BrowserField = {
|
||||
aggregatable: true,
|
||||
category: 'host',
|
||||
description: 'Host ip addresses.',
|
||||
example: '127.0.0.1',
|
||||
fields: {},
|
||||
format: '',
|
||||
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
|
||||
name: 'host.ip',
|
||||
readFromDocValues: false,
|
||||
searchable: true,
|
||||
type: 'ip',
|
||||
};
|
||||
|
||||
const hostIpData: EventFieldsData = {
|
||||
...hostIpFieldFromBrowserField,
|
||||
ariaRowindex: 35,
|
||||
field: 'host.ip',
|
||||
fields: {},
|
||||
format: '',
|
||||
isObjectArray: false,
|
||||
originalValue: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
|
||||
values: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
|
||||
};
|
||||
|
||||
describe('AddToTimelineCellRenderer', () => {
|
||||
describe('When all props are provided', () => {
|
||||
test('it should display the add to timeline button', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<AddToTimelineCellRenderer
|
||||
data={hostIpData}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={hostIpFieldFromBrowserField}
|
||||
linkValue={undefined}
|
||||
timelineId={TimelineId.test}
|
||||
values={hostIpData.values}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.getByTestId('test-add-to-timeline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When browser field data necessary for timeline is unavailable', () => {
|
||||
test('it should not render', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<AddToTimelineCellRenderer
|
||||
data={hostIpData}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={undefined}
|
||||
linkValue={undefined}
|
||||
timelineId={TimelineId.test}
|
||||
values={hostIpData.values}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
expect(screen.queryByTestId('test-add-to-timeline')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { useKibana } from '../../../lib/kibana';
|
||||
import { AlertSummaryRow } from '../helpers';
|
||||
import { useActionCellDataProvider } from './use_action_cell_data_provider';
|
||||
|
||||
const AddToTimelineCell = React.memo<AlertSummaryRow['description']>(
|
||||
({ data, eventId, fieldFromBrowserField, linkValue, timelineId, values }) => {
|
||||
const kibana = useKibana();
|
||||
const { timelines } = kibana.services;
|
||||
const { getAddToTimelineButton } = timelines.getHoverActions();
|
||||
|
||||
const actionCellConfig = useActionCellDataProvider({
|
||||
contextId: timelineId,
|
||||
eventId,
|
||||
field: data.field,
|
||||
fieldFormat: data.format,
|
||||
fieldFromBrowserField,
|
||||
fieldType: data.type,
|
||||
isObjectArray: data.isObjectArray,
|
||||
linkValue,
|
||||
values,
|
||||
});
|
||||
|
||||
const showButton = values != null && !isEmpty(actionCellConfig?.dataProvider);
|
||||
|
||||
if (showButton) {
|
||||
return getAddToTimelineButton({
|
||||
dataProvider: actionCellConfig?.dataProvider,
|
||||
field: data.field,
|
||||
ownFocus: true,
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
AddToTimelineCell.displayName = 'AddToTimelineCell';
|
||||
|
||||
export const AddToTimelineCellRenderer = (props: AlertSummaryRow['description']) => (
|
||||
<AddToTimelineCell {...props} />
|
||||
);
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import { CSSObject } from 'styled-components';
|
||||
import { BrowserField } from '../../../containers/source';
|
||||
import { OverflowField } from '../../tables/helpers';
|
||||
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||
|
@ -21,6 +22,7 @@ export interface FieldValueCellProps {
|
|||
getLinkValue?: (field: string) => string | null;
|
||||
isDraggable?: boolean;
|
||||
linkValue?: string | null | undefined;
|
||||
style?: CSSObject | undefined;
|
||||
values: string[] | null | undefined;
|
||||
}
|
||||
|
||||
|
@ -33,6 +35,7 @@ export const FieldValueCell = React.memo(
|
|||
getLinkValue,
|
||||
isDraggable = false,
|
||||
linkValue,
|
||||
style,
|
||||
values,
|
||||
}: FieldValueCellProps) => {
|
||||
return (
|
||||
|
@ -41,6 +44,7 @@ export const FieldValueCell = React.memo(
|
|||
data-test-subj={`event-field-${data.field}`}
|
||||
direction="column"
|
||||
gutterSize="none"
|
||||
style={style}
|
||||
>
|
||||
{values != null &&
|
||||
values.map((value, i) => {
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BrowserField } from '../../../containers/source';
|
||||
import { PrevalenceCellRenderer } from './prevalence_cell';
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { EventFieldsData } from '../types';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
import { AlertSummaryRow } from '../helpers';
|
||||
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
|
||||
|
||||
jest.mock('../../../lib/kibana');
|
||||
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
|
||||
useAlertPrevalence: jest.fn(),
|
||||
}));
|
||||
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
|
||||
|
||||
const eventId = 'TUWyf3wBFCFU0qRJTauW';
|
||||
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
|
||||
const hostIpFieldFromBrowserField: BrowserField = {
|
||||
aggregatable: true,
|
||||
category: 'host',
|
||||
description: 'Host ip addresses.',
|
||||
example: '127.0.0.1',
|
||||
fields: {},
|
||||
format: '',
|
||||
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
|
||||
name: 'host.ip',
|
||||
readFromDocValues: false,
|
||||
searchable: true,
|
||||
type: 'ip',
|
||||
};
|
||||
const hostIpData: EventFieldsData = {
|
||||
...hostIpFieldFromBrowserField,
|
||||
ariaRowindex: 35,
|
||||
field: 'host.ip',
|
||||
fields: {},
|
||||
format: '',
|
||||
isObjectArray: false,
|
||||
originalValue: [...hostIpValues],
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
const enrichedHostIpData: AlertSummaryRow['description'] = {
|
||||
data: { ...hostIpData },
|
||||
eventId,
|
||||
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
|
||||
isDraggable: false,
|
||||
timelineId: TimelineId.test,
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
describe('PrevalenceCellRenderer', () => {
|
||||
describe('When data is loading', () => {
|
||||
test('it should show the loading spinner', async () => {
|
||||
mockUseAlertPrevalence.mockImplementation(() => ({
|
||||
loading: true,
|
||||
count: 123,
|
||||
error: true,
|
||||
}));
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<PrevalenceCellRenderer {...enrichedHostIpData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When an error was returned', () => {
|
||||
test('it should return null', async () => {
|
||||
mockUseAlertPrevalence.mockImplementation(() => ({
|
||||
loading: false,
|
||||
count: 123,
|
||||
error: true,
|
||||
}));
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<PrevalenceCellRenderer {...enrichedHostIpData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(0);
|
||||
expect(screen.queryByText('123')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When an actual count is returned', () => {
|
||||
test('it should show the count', async () => {
|
||||
mockUseAlertPrevalence.mockImplementation(() => ({
|
||||
loading: false,
|
||||
count: 123,
|
||||
error: false,
|
||||
}));
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<PrevalenceCellRenderer {...enrichedHostIpData} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.getElementsByClassName('euiLoadingSpinner')).toHaveLength(0);
|
||||
expect(screen.queryByText('123')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
|
||||
import { AlertSummaryRow } from '../helpers';
|
||||
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
|
||||
|
||||
const PrevalenceCell = React.memo<AlertSummaryRow['description']>(
|
||||
({ data, values, timelineId }) => {
|
||||
const { loading, count, error } = useAlertPrevalence({
|
||||
field: data.field,
|
||||
timelineId,
|
||||
value: values,
|
||||
signalIndexName: null,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <EuiLoadingSpinner />;
|
||||
} else if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{count}</>;
|
||||
}
|
||||
);
|
||||
|
||||
PrevalenceCell.displayName = 'PrevalenceCell';
|
||||
|
||||
export const PrevalenceCellRenderer = (data: AlertSummaryRow['description']) => {
|
||||
return <PrevalenceCell {...data} />;
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { EuiInMemoryTable } from '@elastic/eui';
|
||||
|
||||
export const SummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
|
||||
.timelines__hoverActionButton {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.flyoutTableHoverActions {
|
||||
.hoverActions-active {
|
||||
.timelines__hoverActionButton,
|
||||
.securitySolution__hoverActionButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.timelines__hoverActionButton,
|
||||
.securitySolution__hoverActionButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BrowserField } from '../../../containers/source';
|
||||
import { SummaryValueCell } from './summary_value_cell';
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { EventFieldsData } from '../types';
|
||||
import { AlertSummaryRow } from '../helpers';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
|
||||
jest.mock('../../../lib/kibana');
|
||||
|
||||
const eventId = 'TUWyf3wBFCFU0qRJTauW';
|
||||
const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'];
|
||||
const hostIpFieldFromBrowserField: BrowserField = {
|
||||
aggregatable: true,
|
||||
category: 'host',
|
||||
description: 'Host ip addresses.',
|
||||
example: '127.0.0.1',
|
||||
fields: {},
|
||||
format: '',
|
||||
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
|
||||
name: 'host.ip',
|
||||
readFromDocValues: false,
|
||||
searchable: true,
|
||||
type: 'ip',
|
||||
};
|
||||
const hostIpData: EventFieldsData = {
|
||||
...hostIpFieldFromBrowserField,
|
||||
ariaRowindex: 35,
|
||||
field: 'host.ip',
|
||||
fields: {},
|
||||
format: '',
|
||||
isObjectArray: false,
|
||||
originalValue: [...hostIpValues],
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
const enrichedHostIpData: AlertSummaryRow['description'] = {
|
||||
data: { ...hostIpData },
|
||||
eventId,
|
||||
fieldFromBrowserField: { ...hostIpFieldFromBrowserField },
|
||||
isDraggable: false,
|
||||
timelineId: TimelineId.test,
|
||||
values: [...hostIpValues],
|
||||
};
|
||||
|
||||
describe('SummaryValueCell', () => {
|
||||
test('it should render', async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<SummaryValueCell {...enrichedHostIpData} />
|
||||
</TestProviders>
|
||||
);
|
||||
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
||||
expect(screen.getAllByTestId('test-filter-for')).toHaveLength(1);
|
||||
expect(screen.getAllByTestId('test-filter-out')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('When in the timeline flyout with timelineId active', () => {
|
||||
test('it should not render the default hover actions', async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<SummaryValueCell {...enrichedHostIpData} timelineId={TimelineId.active} />
|
||||
</TestProviders>
|
||||
);
|
||||
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('test-filter-for')).toBeNull();
|
||||
expect(screen.queryByTestId('test-filter-out')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ActionCell } from './action_cell';
|
||||
import { FieldValueCell } from './field_value_cell';
|
||||
import { AlertSummaryRow } from '../helpers';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
|
||||
export const SummaryValueCell: React.FC<AlertSummaryRow['description']> = ({
|
||||
data,
|
||||
eventId,
|
||||
fieldFromBrowserField,
|
||||
isDraggable,
|
||||
linkValue,
|
||||
timelineId,
|
||||
values,
|
||||
}) => (
|
||||
<>
|
||||
<FieldValueCell
|
||||
contextId={timelineId}
|
||||
data={data}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={fieldFromBrowserField}
|
||||
linkValue={linkValue}
|
||||
isDraggable={isDraggable}
|
||||
style={{ flexGrow: 0 }}
|
||||
values={values}
|
||||
/>
|
||||
{timelineId !== TimelineId.active && (
|
||||
<ActionCell
|
||||
contextId={timelineId}
|
||||
data={data}
|
||||
eventId={eventId}
|
||||
fieldFromBrowserField={fieldFromBrowserField}
|
||||
linkValue={linkValue}
|
||||
timelineId={timelineId}
|
||||
values={values}
|
||||
applyWidthAndPadding={false}
|
||||
hideAddToTimeline={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
SummaryValueCell.displayName = 'SummaryValueCell';
|
|
@ -92,15 +92,17 @@ export const useActionCellDataProvider = ({
|
|||
} else if (fieldType === IP_FIELD_TYPE) {
|
||||
id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`;
|
||||
if (isString(value) && !isEmpty(value)) {
|
||||
let addresses = value;
|
||||
try {
|
||||
const addresses = JSON.parse(value);
|
||||
if (isArray(addresses)) {
|
||||
valueAsString = addresses.join(',');
|
||||
addresses.forEach((ip) => memo.dataProvider.push(getDataProvider(field, id, ip)));
|
||||
}
|
||||
addresses = JSON.parse(value);
|
||||
} catch (_) {
|
||||
// Default to keeping the existing string value
|
||||
}
|
||||
if (isArray(addresses)) {
|
||||
valueAsString = addresses.join(',');
|
||||
addresses.forEach((ip) => memo.dataProvider.push(getDataProvider(field, id, ip)));
|
||||
}
|
||||
memo.dataProvider.push(getDataProvider(field, id, addresses));
|
||||
memo.stringValues.push(valueAsString);
|
||||
return memo;
|
||||
}
|
||||
|
|
|
@ -22,13 +22,42 @@ export const OVERVIEW = i18n.translate('xpack.securitySolution.alertDetails.over
|
|||
defaultMessage: 'Overview',
|
||||
});
|
||||
|
||||
export const HIGHLIGHTES_FIELDS = i18n.translate(
|
||||
export const HIGHLIGHTED_FIELDS = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.overview.highlightedFields',
|
||||
{
|
||||
defaultMessage: 'Highlighted fields',
|
||||
}
|
||||
);
|
||||
|
||||
export const HIGHLIGHTED_FIELDS_FIELD = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.overview.highlightedFields.field',
|
||||
{
|
||||
defaultMessage: 'Field',
|
||||
}
|
||||
);
|
||||
|
||||
export const HIGHLIGHTED_FIELDS_VALUE = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.overview.highlightedFields.value',
|
||||
{
|
||||
defaultMessage: 'Value',
|
||||
}
|
||||
);
|
||||
|
||||
export const HIGHLIGHTED_FIELDS_ALERT_PREVALENCE = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.overview.highlightedFields.alertPrevalence',
|
||||
{
|
||||
defaultMessage: 'Alert Prevalence',
|
||||
}
|
||||
);
|
||||
|
||||
export const HIGHLIGHTED_FIELDS_ALERT_PREVALENCE_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.alertDetails.overview.highlightedFields.alertPrevalenceTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'The total count of alerts with the same value within the currently selected timerange. This value is not affected by additional filters.',
|
||||
}
|
||||
);
|
||||
|
||||
export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', {
|
||||
defaultMessage: 'Table',
|
||||
});
|
||||
|
|
|
@ -97,6 +97,7 @@ interface Props {
|
|||
enableOverflowButton?: boolean;
|
||||
field: string;
|
||||
goGetTimelineId?: (args: boolean) => void;
|
||||
hideAddToTimeline?: boolean;
|
||||
hideTopN?: boolean;
|
||||
isObjectArray: boolean;
|
||||
onFilterAdded?: () => void;
|
||||
|
@ -137,6 +138,7 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
field,
|
||||
goGetTimelineId,
|
||||
isObjectArray,
|
||||
hideAddToTimeline = false,
|
||||
hideTopN = false,
|
||||
onFilterAdded,
|
||||
ownFocus,
|
||||
|
@ -218,6 +220,7 @@ export const HoverActions: React.FC<Props> = React.memo(
|
|||
enableOverflowButton: enableOverflowButton && !isCaseView,
|
||||
field,
|
||||
handleHoverActionClicked,
|
||||
hideAddToTimeline,
|
||||
hideTopN,
|
||||
isCaseView,
|
||||
isObjectArray,
|
||||
|
|
|
@ -22,6 +22,7 @@ describe('useHoverActionItems', () => {
|
|||
defaultFocusedButtonRef: null,
|
||||
field: 'kibana.alert.rule.name',
|
||||
handleHoverActionClicked: jest.fn(),
|
||||
hideAddToTimeline: false,
|
||||
hideTopN: false,
|
||||
isCaseView: false,
|
||||
isObjectArray: false,
|
||||
|
@ -274,4 +275,21 @@ describe('useHoverActionItems', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('when timeline button is disabled, it should not show', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => {
|
||||
const testProps = {
|
||||
...defaultProps,
|
||||
hideAddToTimeline: true,
|
||||
};
|
||||
return useHoverActionItems(testProps);
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current.allActionItems.forEach((actionItem) => {
|
||||
expect(actionItem.props['data-test-subj']).not.toEqual('hover-actions-add-timeline');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,6 +30,7 @@ export interface UseHoverActionItemsProps {
|
|||
enableOverflowButton?: boolean;
|
||||
field: string;
|
||||
handleHoverActionClicked: () => void;
|
||||
hideAddToTimeline: boolean;
|
||||
hideTopN: boolean;
|
||||
isCaseView: boolean;
|
||||
isObjectArray: boolean;
|
||||
|
@ -60,6 +61,7 @@ export const useHoverActionItems = ({
|
|||
field,
|
||||
handleHoverActionClicked,
|
||||
hideTopN,
|
||||
hideAddToTimeline,
|
||||
isCaseView,
|
||||
isObjectArray,
|
||||
isOverflowPopoverOpen,
|
||||
|
@ -204,7 +206,7 @@ export const useHoverActionItems = ({
|
|||
})}
|
||||
</div>
|
||||
) : null,
|
||||
values != null && (draggableId != null || !isEmpty(dataProvider)) ? (
|
||||
values != null && (draggableId != null || !isEmpty(dataProvider)) && !hideAddToTimeline ? (
|
||||
<div data-test-subj="hover-actions-add-timeline" key="hover-actions-add-timeline">
|
||||
{getAddToTimelineButton({
|
||||
Component: enableOverflowButton ? EuiContextMenuItem : undefined,
|
||||
|
@ -258,6 +260,7 @@ export const useHoverActionItems = ({
|
|||
getFilterForValueButton,
|
||||
getFilterOutValueButton,
|
||||
handleHoverActionClicked,
|
||||
hideAddToTimeline,
|
||||
hideTopN,
|
||||
isObjectArray,
|
||||
onFilterAdded,
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../common/constants';
|
||||
import { useGlobalTime } from '../use_global_time';
|
||||
import { GenericBuckets } from '../../../../common/search_strategy';
|
||||
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
|
||||
import { TimelineId } from '../../../../common/types';
|
||||
import { useDeepEqualSelector } from '../../hooks/use_selector';
|
||||
import { inputsSelectors } from '../../store';
|
||||
|
||||
const ALERT_PREVALENCE_AGG = 'countOfAlertsWithSameFieldAndValue';
|
||||
export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count';
|
||||
|
||||
interface UseAlertPrevalenceOptions {
|
||||
field: string;
|
||||
value: string | string[] | undefined | null;
|
||||
timelineId: string;
|
||||
signalIndexName: string | null;
|
||||
}
|
||||
|
||||
interface UserAlertPrevalenceResult {
|
||||
loading: boolean;
|
||||
count: undefined | number;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export const useAlertPrevalence = ({
|
||||
field,
|
||||
value,
|
||||
timelineId,
|
||||
signalIndexName,
|
||||
}: UseAlertPrevalenceOptions): UserAlertPrevalenceResult => {
|
||||
const timelineTime = useDeepEqualSelector((state) =>
|
||||
inputsSelectors.timelineTimeRangeSelector(state)
|
||||
);
|
||||
const globalTime = useGlobalTime();
|
||||
|
||||
const { to, from } = timelineId === TimelineId.active ? timelineTime : globalTime;
|
||||
const [initialQuery] = useState(() => generateAlertPrevalenceQuery(field, value, from, to));
|
||||
|
||||
const { loading, data, setQuery } = useQueryAlerts<{}, AlertPrevalenceAggregation>({
|
||||
query: initialQuery,
|
||||
indexName: signalIndexName,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(generateAlertPrevalenceQuery(field, value, from, to));
|
||||
}, [setQuery, field, value, from, to]);
|
||||
|
||||
let count: undefined | number;
|
||||
if (data) {
|
||||
const buckets = data.aggregations?.[ALERT_PREVALENCE_AGG]?.buckets;
|
||||
if (buckets && buckets.length > 0) {
|
||||
/**
|
||||
* Currently for array fields like `process.args` or potentially any `ip` fields
|
||||
* We show the combined count of all occurences of the value, even though those values
|
||||
* could be shared across multiple documents. To make this clearer, we should separate
|
||||
* these values into separate table rows
|
||||
*/
|
||||
count = buckets?.reduce((sum, bucket) => sum + (bucket?.doc_count ?? 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const error = !loading && count === undefined;
|
||||
|
||||
return {
|
||||
loading,
|
||||
count,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
const generateAlertPrevalenceQuery = (
|
||||
field: string,
|
||||
value: string | string[] | undefined | null,
|
||||
from: string,
|
||||
to: string
|
||||
) => {
|
||||
const actualValue = Array.isArray(value) && value.length === 1 ? value[0] : value;
|
||||
let query;
|
||||
query = {
|
||||
bool: {
|
||||
must: {
|
||||
match: {
|
||||
[field]: actualValue,
|
||||
},
|
||||
},
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (Array.isArray(value) && value.length > 1) {
|
||||
const shouldValues = value.map((val) => ({ match: { [field]: val } }));
|
||||
query = {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
must: [
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
should: shouldValues,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
size: 0,
|
||||
aggs: {
|
||||
[ALERT_PREVALENCE_AGG]: {
|
||||
terms: {
|
||||
field,
|
||||
size: DEFAULT_MAX_TABLE_QUERY_SIZE,
|
||||
},
|
||||
},
|
||||
},
|
||||
query,
|
||||
runtime_mappings: {},
|
||||
};
|
||||
};
|
||||
|
||||
export interface AlertPrevalenceAggregation {
|
||||
[ALERT_PREVALENCE_AGG]: {
|
||||
buckets: GenericBuckets[];
|
||||
};
|
||||
}
|
|
@ -7,11 +7,15 @@
|
|||
import React from 'react';
|
||||
|
||||
export const mockHoverActions = {
|
||||
getAddToTimelineButton: () => <>{'Add To Timeline'}</>,
|
||||
getColumnToggleButton: () => <>{'Column Toggle'}</>,
|
||||
getCopyButton: () => <>{'Copy button'}</>,
|
||||
getFilterForValueButton: () => <>{'Filter button'}</>,
|
||||
getFilterOutValueButton: () => <>{'Filter out button'}</>,
|
||||
getAddToTimelineButton: () => (
|
||||
<span data-test-subj="test-add-to-timeline">{'Add To Timeline'}</span>
|
||||
),
|
||||
getColumnToggleButton: () => <span data-test-subj="test-column-toggle">{'Column Toggle'}</span>,
|
||||
getCopyButton: () => <span data-test-subj="test-copy-button">{'Copy button'}</span>,
|
||||
getFilterForValueButton: () => <span data-test-subj="test-filter-for">{'Filter button'}</span>,
|
||||
getFilterOutValueButton: () => (
|
||||
<span data-test-subj="test-filter-out">{'Filter out button'}</span>
|
||||
),
|
||||
getOverflowButton: (props: { field: string }) => (
|
||||
<div data-test-subj={`more-actions-${props.field}`} {...props}>
|
||||
{'Overflow button'}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue