[TIP] Add to timeline (#138836)

* [TIP] Add to timeline

- add button to all cells in the indicators table
- add button to a new actions column for the indicator flyout table
- add button to barchart legend
- create new providers (kibana context and security context) for unit tests and storybook
- change all Storybook console.log to window.alert to follow the EUI pattern
- fix broken Storybook indicator table story

https://github.com/elastic/security-team/issues/4557
This commit is contained in:
Philippe Oberti 2022-08-24 12:41:54 +02:00 committed by GitHub
parent 1b541c4d33
commit e3181e65f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1321 additions and 372 deletions

View file

@ -18,6 +18,10 @@ Verify your node version [here](https://github.com/elastic/kibana/blob/main/.nod
**Run Kibana:**
> **Important:**
>
> See here to get your `kibana.yaml` to enable the Threat Intelligence plugin.
```
yarn kbn reset && yarn kbn bootstrap
yarn start --no-base-path

View file

@ -5,6 +5,9 @@
* 2.0.
*/
/**
* Enum of indicator fields supported by the Threat Intelligence plugin.
*/
export enum RawIndicatorFieldId {
Type = 'threat.indicator.type',
FirstSeen = 'threat.indicator.first_seen',
@ -21,11 +24,17 @@ export enum RawIndicatorFieldId {
TimeStamp = '@timestamp',
}
/**
* Threat Intelligence Indicator interface.
*/
export interface Indicator {
_id?: unknown;
fields: Partial<Record<RawIndicatorFieldId, unknown[]>>;
}
/**
* Used to create new Indicators, used mainly in jest unit tests and Storybook stories.
*/
export const generateMockIndicator = (): Indicator => ({
fields: {
'@timestamp': ['2022-01-01T01:01:01.000Z'],
@ -37,6 +46,9 @@ export const generateMockIndicator = (): Indicator => ({
_id: Math.random(),
});
/**
* Used to create new url-type Indicators, used mainly in jest unit tests and Storybook stories.
*/
export const generateMockUrlIndicator = (): Indicator => {
const indicator = generateMockIndicator();
@ -46,6 +58,9 @@ export const generateMockUrlIndicator = (): Indicator => {
return indicator;
};
/**
* Used to create new file-type Indicators, used mainly in jest unit tests and Storybook stories.
*/
export const generateMockFileIndicator = (): Indicator => {
const indicator = generateMockIndicator();

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import { login } from '../../tasks/login';
import { login } from '../tasks/login';
import {
EMPTY_PAGE_BODY,
EMPTY_PAGE_DOCS_LINK,
EMPTY_PAGE_INTEGRATIONS_LINK,
} from '../../screens/empty_page';
} from '../screens/empty_page';
const THREAT_INTEL_PATH = '/app/security/threat_intelligence/';

View file

@ -25,10 +25,10 @@ import {
ENDING_BREADCRUMB,
FIELD_BROWSER,
FIELD_BROWSER_MODAL,
} from '../../screens/indicators';
import { login } from '../../tasks/login';
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
import { selectRange } from '../../tasks/select_range';
} from '../screens/indicators';
import { login } from '../tasks/login';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { selectRange } from '../tasks/select_range';
before(() => {
login();

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
BARCHART_TIMELINE_BUTTON,
FLYOUT_CLOSE_BUTTON,
FLYOUT_TABLE_ROW_TIMELINE_BUTTON,
INDICATOR_TYPE_CELL,
INDICATORS_TABLE_CELL_TIMELINE_BUTTON,
TIMELINE_DRAGGABLE_ITEM,
TOGGLE_FLYOUT_BUTTON,
UNTITLED_TIMELINE_BUTTON,
} from '../screens/indicators';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { login } from '../tasks/login';
import { selectRange } from '../tasks/select_range';
const THREAT_INTELLIGENCE = '/app/security/threat_intelligence/indicators';
before(() => {
login();
});
describe('Indicators', () => {
before(() => {
esArchiverLoad('threat_intelligence');
});
after(() => {
esArchiverUnload('threat_intelligence');
});
describe('Indicators timeline interactions', () => {
before(() => {
cy.visit(THREAT_INTELLIGENCE);
selectRange();
});
it('should add entry in timeline when clicking in the barchart legend', () => {
cy.get(BARCHART_TIMELINE_BUTTON).should('exist').first().click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should add entry in timeline when clicking in an indicator table cell', () => {
cy.get(INDICATOR_TYPE_CELL).first().trigger('mouseover');
cy.get(INDICATORS_TABLE_CELL_TIMELINE_BUTTON).should('exist').first().click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should add entry in timeline when clicking in an indicators flyout row', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_TABLE_ROW_TIMELINE_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
});
});

View file

@ -17,6 +17,8 @@ export const INDICATORS_TABLE = `[data-test-subj="tiIndicatorsTable"]`;
export const TOGGLE_FLYOUT_BUTTON = `[data-test-subj="tiToggleIndicatorFlyoutButton"]`;
export const FLYOUT_CLOSE_BUTTON = `[data-test-subj="euiFlyoutCloseButton"]`;
export const FLYOUT_TITLE = `[data-test-subj="tiIndicatorFlyoutTitle"]`;
export const FLYOUT_TABS = `[data-test-subj="tiIndicatorFlyoutTabs"]`;
@ -46,3 +48,14 @@ export const FIELD_BROWSER_MODAL = `[data-test-subj="fields-browser-container"]`
export const FIELD_BROWSER_MODAL_SOURCE_CHECKBOX = `[data-test-subj="field-_source-checkbox"]`;
export const FIELD_BROWSER_CLOSE = `[data-test-subj="close"]`;
export const BARCHART_TIMELINE_BUTTON = '[data-test-subj="tiTimelineButton"]';
export const INDICATORS_TABLE_CELL_TIMELINE_BUTTON =
'[data-test-subj="tiIndicatorsTableCellTimelineButton"]';
export const FLYOUT_TABLE_ROW_TIMELINE_BUTTON = '[data-test-subj="tiFlyoutTableRowTimelineButton"]';
export const UNTITLED_TIMELINE_BUTTON = '[data-test-subj="flyoutOverlay"]';
export const TIMELINE_DRAGGABLE_ITEM = '[data-test-subj="providerContainer"]';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* Mock to map an indicator field to its type.
*/
export const generateFieldTypeMap = (): { [id: string]: string } => ({
'@timestamp': 'date',
'threat.indicator.ip': 'ip',
'threat.indicator.first_seen': 'date',
'threat.feed.name': 'string',
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { HoverActionsConfig } from '@kbn/timelines-plugin/public/components/hover_actions';
import { EuiButtonIcon } from '@elastic/eui';
import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
/**
* Returns a default object to mock the timelines plugin for our unit tests and storybook stories.
* The button mocks a window.alert onClick event.
*/
export const mockKibanaTimelinesService: TimelinesUIStart = {
getHoverActions(): HoverActionsConfig {
return {
getAddToTimelineButton: () => (
<EuiButtonIcon
iconType="timeline"
iconSize="s"
onClick={() => window.alert('Add to Timeline button clicked')}
/>
),
} as unknown as HoverActionsConfig;
},
} as unknown as TimelinesUIStart;

View file

@ -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 React from 'react';
import { EuiText } from '@elastic/eui';
import { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
/**
* Returns a default object to mock the triggers actions ui plugin for our unit tests and storybook stories.
*/
export const mockTriggersActionsUiService: TriggersActionsStart = {
getFieldBrowser: () => (
<EuiText style={{ display: 'inline' }} size="xs">
Fields
</EuiText>
),
} as unknown as TriggersActionsStart;

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IUiSettingsClient } from '@kbn/core/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants';
/**
* Creates an object to pass to the uiSettings property when creating a KibanaReacrContext (see src/plugins/kibana_react/public/context/context.tsx).
* @param dateFormat defaults to ''
* @param timezone defaults to 'UTC
* @returns the object {@link IUiSettingsClient}
*/
export const mockUiSettingsService = (dateFormat: string = '', timezone: string = 'UTC') =>
({
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: dateFormat,
[DEFAULT_DATE_FORMAT_TZ]: timezone,
};
// @ts-expect-error
return settings[key];
},
} as unknown as IUiSettingsClient);
/**
* Mocks date format or timezone for testing.
* @param key dateFormat | dateFormat:tz
* @returns string
*/
export function mockUiSetting(key: string): string | undefined {
if (key === 'dateFormat') {
return 'MMM D, YYYY @ HH:mm:ss.SSS';
}
if (key === 'dateFormat:tz') {
return 'America/New_York';
}
}

View file

@ -8,7 +8,8 @@
import React, { ReactNode, VFC } from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { CoreStart, IUiSettingsClient } from '@kbn/core/public';
import { IUiSettingsClient } from '@kbn/core/public';
import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import { SecuritySolutionContext } from '../../containers/security_solution_context';
import { getSecuritySolutionContextMock } from './mock_security_context';
@ -21,6 +22,10 @@ export interface KibanaContextMock {
* For the core ui-settings package (see {@link IUiSettingsClient})
*/
uiSettings?: IUiSettingsClient;
/**
* For the timelines plugin
*/
timelines: TimelinesUIStart;
}
export interface StoryProvidersComponentProps {
@ -42,7 +47,7 @@ export const StoryProvidersComponent: VFC<StoryProvidersComponentProps> = ({
children,
kibana,
}) => {
const KibanaReactContext = createKibanaReactContext(kibana as CoreStart);
const KibanaReactContext = createKibanaReactContext(kibana);
const securitySolutionContextMock = getSecuritySolutionContextMock();
return (

View file

@ -7,17 +7,18 @@
import moment from 'moment/moment';
import React, { FC } from 'react';
import { BehaviorSubject } from 'rxjs';
import { I18nProvider } from '@kbn/i18n-react';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import type { IStorage } from '@kbn/kibana-utils-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { BehaviorSubject } from 'rxjs';
import { getSecuritySolutionContextMock } from './mock_security_context';
import { mockUiSetting } from './mock_kibana_ui_setting';
import { createTGridMocks } from '@kbn/timelines-plugin/public/mock';
import { KibanaContext } from '../../hooks/use_kibana';
import { SecuritySolutionPluginContext } from '../../types';
import { getSecuritySolutionContextMock } from './mock_security_context';
import { mockUiSetting } from './mock_kibana_ui_settings_service';
import { SecuritySolutionContext } from '../../containers/security_solution_context';
export const localStorageMock = (): IStorage => {
@ -90,6 +91,8 @@ const dataServiceMock = {
},
};
const timelinesServiceMock = createTGridMocks();
const core = coreMock.createStart();
const coreServiceMock = {
...core,
@ -106,6 +109,7 @@ export const mockedServices = {
triggersActionsUi: {
getFieldBrowser: jest.fn().mockReturnValue(null),
},
timelines: timelinesServiceMock,
};
export const TestProvidersComponent: FC = ({ children }) => (

View file

@ -8,7 +8,7 @@
import React from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants';
import { mockUiSettingsService } from '../../common/mocks/mock_kibana_ui_settings_service';
import { DateFormatter } from './date_formatter';
const mockValidStringDate = '1 Jan 2022 00:00:00 GMT';
@ -21,19 +21,9 @@ export default {
};
export function Default() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService(),
} as unknown as CoreStart);
return (
<KibanaReactContext.Provider>
@ -43,19 +33,9 @@ export function Default() {
}
export function UserTimeZone() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'America/New_York',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService('', 'America/New York'),
});
return (
<KibanaReactContext.Provider>
@ -65,19 +45,9 @@ export function UserTimeZone() {
}
export function UserDateFormat() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: 'MMM Do YY',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService('MMM Do YY', 'UTC'),
});
return (
<KibanaReactContext.Provider>
@ -87,18 +57,9 @@ export function UserDateFormat() {
}
export function CustomDateFormat() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService('', 'UTC'),
});
return (
<KibanaReactContext.Provider>
@ -108,19 +69,9 @@ export function CustomDateFormat() {
}
export function InvalidStringDate() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService(),
});
return (
<KibanaReactContext.Provider>

View file

@ -6,9 +6,9 @@
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../../common/constants';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
import { mockUiSettingsService } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { generateMockIndicator } from '../../../../../common/types/indicator';
import { IndicatorField } from './indicator_field';
@ -18,13 +18,12 @@ export default {
};
const mockIndicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'threat.indicator.ip': 'ip',
'threat.indicator.first_seen': 'date',
};
const mockFieldTypesMap = generateFieldTypeMap();
export function Default() {
const mockField = 'threat.indicator.ip';
return (
<IndicatorField indicator={mockIndicator} field={mockField} fieldTypesMap={mockFieldTypesMap} />
);
@ -32,27 +31,16 @@ export function Default() {
export function IncorrectField() {
const mockField = 'abc';
return (
<IndicatorField indicator={mockIndicator} field={mockField} fieldTypesMap={mockFieldTypesMap} />
);
}
export function HandlesDates() {
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({ uiSettings: mockUiSettingsService() });
const mockField = 'threat.indicator.first_seen';
return (
<KibanaReactContext.Provider>
<IndicatorField

View file

@ -11,12 +11,10 @@ import { IndicatorField } from './indicator_field';
import { generateMockIndicator } from '../../../../../common/types/indicator';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { TestProvidersComponent } from '../../../../common/mocks/test_providers';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
const mockIndicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'threat.indicator.ip': 'ip',
'threat.indicator.first_seen': 'date',
};
const mockFieldTypesMap = generateFieldTypeMap();
describe('<IndicatorField />', () => {
beforeEach(() => {});

View file

@ -12,17 +12,23 @@ import { DateFormatter } from '../../../../components/date_formatter';
import { unwrapValue } from '../../lib/unwrap_value';
export interface IndicatorFieldProps {
/**
* Indicator to display the field value from (see {@link Indicator}).
*/
indicator: Indicator;
/**
* The field to get the indicator's value for.
*/
field: string;
/**
* An object to know what type the field is ('file', 'date', ...).
*/
fieldTypesMap: { [id: string]: string };
}
/**
* Takes an indicator object, a field and a field => type object to returns the correct value to display
* @param indicator see {@link Indicator}
* @param field the field to get the indicator's value for
* @param fieldTypesMap an object to know what type ('file', 'date', ...) the field is
* @returns If the type is a 'date', returns the {@link DateFormatter} component, else returns the value or {@link EMPTY_VALUE}
* Takes an indicator object, a field and a field => type object to returns the correct value to display.
* @returns If the type is a 'date', returns the {@link DateFormatter} component, else returns the value or {@link EMPTY_VALUE}.
*/
export const IndicatorField: VFC<IndicatorFieldProps> = ({ indicator, field, fieldTypesMap }) => {
const value = unwrapValue(indicator, field as RawIndicatorFieldId);

View file

@ -9,6 +9,9 @@ import moment from 'moment';
import React from 'react';
import { Story } from '@storybook/react';
import { TimeRangeBounds } from '@kbn/data-plugin/common';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { ChartSeries } from '../../hooks/use_aggregated_indicators';
import { IndicatorsBarChart } from './indicators_barchart';
@ -45,12 +48,19 @@ const mockIndicators: ChartSeries[] = [
},
];
const validDate: string = '1 Jan 2022 00:00:00 GMT';
const numberOfDays = 1;
const mockDateRange: TimeRangeBounds = {
min: moment(validDate),
max: moment(validDate).add(numberOfDays, 'days'),
};
const mockHeight = '500px';
const mockField: string = 'threat.indicator.ip';
const KibanaReactContext = createKibanaReactContext({
timelines: mockKibanaTimelinesService,
} as unknown as CoreStart);
export default {
component: IndicatorsBarChart,
@ -58,13 +68,28 @@ export default {
};
export const Default: Story<void> = () => (
<IndicatorsBarChart indicators={mockIndicators} dateRange={mockDateRange} />
<KibanaReactContext.Provider>
<IndicatorsBarChart indicators={mockIndicators} field={mockField} dateRange={mockDateRange} />
</KibanaReactContext.Provider>
);
export const NoData: Story<void> = () => (
<IndicatorsBarChart indicators={[]} dateRange={mockDateRange} />
<KibanaReactContext.Provider>
<IndicatorsBarChart indicators={[]} field={''} dateRange={mockDateRange} />
</KibanaReactContext.Provider>
);
export const CustomHeight: Story<void> = () => (
<IndicatorsBarChart indicators={mockIndicators} dateRange={mockDateRange} height={mockHeight} />
);
export const CustomHeight: Story<void> = () => {
const mockHeight = '500px';
return (
<KibanaReactContext.Provider>
<IndicatorsBarChart
indicators={mockIndicators}
field={mockField}
dateRange={mockDateRange}
height={mockHeight}
/>
</KibanaReactContext.Provider>
);
};

View file

@ -16,39 +16,44 @@ import { IndicatorsBarChart } from './indicators_barchart';
moment.suppressDeprecationWarnings = true;
moment.tz.setDefault('UTC');
const mockIndicators: ChartSeries[] = [
{
x: '1 Jan 2022 00:00:00 GMT',
y: 0,
g: '[Filebeat] AbuseCH Malware',
},
{
x: '1 Jan 2022 00:00:00 GMT',
y: 10,
g: '[Filebeat] AbuseCH MalwareBazaar',
},
{
x: '1 Jan 2022 12:00:00 GMT',
y: 25,
g: '[Filebeat] AbuseCH Malware',
},
{
x: '1 Jan 2022 18:00:00 GMT',
y: 15,
g: '[Filebeat] AbuseCH MalwareBazaar',
},
];
const validDate: string = '1 Jan 2022 00:00:00 GMT';
const mockDateRange: TimeRangeBounds = {
min: moment(validDate),
max: moment(validDate).add(1, 'days'),
};
describe('<IndicatorsBarChart />', () => {
it('should render barchart', () => {
const mockIndicators: ChartSeries[] = [
{
x: '1 Jan 2022 00:00:00 GMT',
y: 0,
g: '[Filebeat] AbuseCH Malware',
},
{
x: '1 Jan 2022 00:00:00 GMT',
y: 10,
g: '[Filebeat] AbuseCH MalwareBazaar',
},
{
x: '1 Jan 2022 12:00:00 GMT',
y: 25,
g: '[Filebeat] AbuseCH Malware',
},
{
x: '1 Jan 2022 18:00:00 GMT',
y: 15,
g: '[Filebeat] AbuseCH MalwareBazaar',
},
];
const validDate: string = '1 Jan 2022 00:00:00 GMT';
const mockDateRange: TimeRangeBounds = {
min: moment(validDate),
max: moment(validDate).add(1, 'days'),
};
const mockField: string = 'threat.indicator.ip';
const component = render(
<TestProvidersComponent>
<IndicatorsBarChart indicators={mockIndicators} dateRange={mockDateRange} />
<IndicatorsBarChart
indicators={mockIndicators}
dateRange={mockDateRange}
field={mockField}
/>
</TestProvidersComponent>
);

View file

@ -9,28 +9,55 @@ import React, { VFC } from 'react';
import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiThemeProvider } from '@elastic/eui';
import { TimeRangeBounds } from '@kbn/data-plugin/common';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { barChartTimeAxisLabelFormatter } from '../../../../common/utils/dates';
import { ChartSeries } from '../../hooks/use_aggregated_indicators';
export const TIMELINE_BUTTON_TEST_ID = 'tiTimelineButton';
const ID = 'tiIndicator';
const DEFAULT_CHART_HEIGHT = '200px';
const DEFAULT_CHART_WIDTH = '100%';
export interface IndicatorsBarChartProps {
/**
* Array of indicators already processed to be consumed by the BarSeries component from the @elastic/charts library.
*/
indicators: ChartSeries[];
/**
* Min and max dates to nicely format the label in the @elastic/charts Axis component.
*/
dateRange: TimeRangeBounds;
/**
* Indicator field selected in the IndicatorFieldSelector component, passed to the {@link AddToTimeline} to populate the timeline.
*/
field: string;
/**
* Option height value to override the default {@link DEFAULT_CHART_HEIGHT} default barchart height.
*/
height?: string;
}
/**
* Displays a barchart of aggregated indicators using the @elastic/charts library.
*/
export const IndicatorsBarChart: VFC<IndicatorsBarChartProps> = ({
indicators,
dateRange,
field,
height = DEFAULT_CHART_HEIGHT,
}) => {
return (
<EuiThemeProvider>
<Chart size={{ width: DEFAULT_CHART_WIDTH, height }}>
<Settings showLegend showLegendExtra legendPosition={Position.Right} />
<Settings
showLegend
showLegendExtra
legendPosition={Position.Right}
legendAction={({ label }) => (
<AddToTimeline data={label} field={field} testId={TIMELINE_BUTTON_TEST_ID} />
)}
/>
<Axis
id={`${ID}TimeAxis`}
position={Position.Bottom}

View file

@ -15,6 +15,7 @@ import { TimeRange } from '@kbn/es-query';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { IUiSettingsClient } from '@kbn/core/public';
import { StoryProvidersComponent } from '../../../../common/mocks/story_providers';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { Aggregation, AGGREGATION_NAME } from '../../hooks/use_aggregated_indicators';
import { DEFAULT_TIME_RANGE } from '../../hooks/use_filters/utils';
import { IndicatorsBarChartWrapper } from './indicators_barchart_wrapper';
@ -24,93 +25,101 @@ export default {
title: 'IndicatorsBarChartWrapper',
};
const mockTimeRange: TimeRange = DEFAULT_TIME_RANGE;
const mockIndexPattern: DataView = {
fields: [
{
name: '@timestamp',
type: 'date',
} as DataViewField,
{
name: 'threat.feed.name',
type: 'string',
} as DataViewField,
],
} as DataView;
export const Default: Story<void> = () => {
const mockTimeRange: TimeRange = DEFAULT_TIME_RANGE;
const validDate: string = '1 Jan 2022 00:00:00 GMT';
const numberOfDays: number = 1;
const aggregation1: Aggregation = {
events: {
buckets: [
const mockIndexPattern: DataView = {
fields: [
{
doc_count: 0,
key: 1641016800000,
key_as_string: '1 Jan 2022 06:00:00 GMT',
},
name: '@timestamp',
type: 'date',
} as DataViewField,
{
doc_count: 10,
key: 1641038400000,
key_as_string: '1 Jan 2022 12:00:00 GMT',
},
name: 'threat.feed.name',
type: 'string',
} as DataViewField,
],
},
doc_count: 0,
key: '[Filebeat] AbuseCH Malware',
};
const aggregation2: Aggregation = {
events: {
buckets: [
{
doc_count: 20,
key: 1641016800000,
key_as_string: '1 Jan 2022 06:00:00 GMT',
},
{
doc_count: 8,
key: 1641038400000,
key_as_string: '1 Jan 2022 12:00:00 GMT',
},
],
},
doc_count: 0,
key: '[Filebeat] AbuseCH MalwareBazaar',
};
const mockData = {
search: {
search: () =>
of({
rawResponse: {
aggregations: {
[AGGREGATION_NAME]: {
buckets: [aggregation1, aggregation2],
} as DataView;
const validDate: string = '1 Jan 2022 00:00:00 GMT';
const numberOfDays: number = 1;
const aggregation1: Aggregation = {
events: {
buckets: [
{
doc_count: 0,
key: 1641016800000,
key_as_string: '1 Jan 2022 06:00:00 GMT',
},
{
doc_count: 10,
key: 1641038400000,
key_as_string: '1 Jan 2022 12:00:00 GMT',
},
],
},
doc_count: 0,
key: '[Filebeat] AbuseCH Malware',
};
const aggregation2: Aggregation = {
events: {
buckets: [
{
doc_count: 20,
key: 1641016800000,
key_as_string: '1 Jan 2022 06:00:00 GMT',
},
{
doc_count: 8,
key: 1641038400000,
key_as_string: '1 Jan 2022 12:00:00 GMT',
},
],
},
doc_count: 0,
key: '[Filebeat] AbuseCH MalwareBazaar',
};
const dataServiceMock = {
search: {
search: () =>
of({
rawResponse: {
aggregations: {
[AGGREGATION_NAME]: {
buckets: [aggregation1, aggregation2],
},
},
},
},
}),
},
query: {
timefilter: {
timefilter: {
calculateBounds: () => ({
min: moment(validDate),
max: moment(validDate).add(numberOfDays, 'days'),
}),
},
query: {
timefilter: {
timefilter: {
calculateBounds: () => ({
min: moment(validDate),
max: moment(validDate).add(numberOfDays, 'days'),
}),
},
},
filterManager: {
getFilters: () => {},
setFilters: () => {},
getUpdates$: () => of(),
},
},
filterManager: {
getFilters: () => {},
setFilters: () => {},
getUpdates$: () => of(),
},
},
} as unknown as DataPublicPluginStart;
} as unknown as DataPublicPluginStart;
const mockUiSettings = { get: () => {} } as unknown as IUiSettingsClient;
const uiSettingsMock = {
get: () => {},
} as unknown as IUiSettingsClient;
const timelinesMock = mockKibanaTimelinesService;
export const Default: Story<void> = () => {
return (
<StoryProvidersComponent kibana={{ data: mockData, uiSettings: mockUiSettings }}>
<StoryProvidersComponent
kibana={{ data: dataServiceMock, uiSettings: uiSettingsMock, timelines: timelinesMock }}
>
<IndicatorsBarChartWrapper timeRange={mockTimeRange} indexPattern={mockIndexPattern} />
</StoryProvidersComponent>
);

View file

@ -18,13 +18,25 @@ import { IndicatorsBarChart } from '../indicators_barchart/indicators_barchart';
const DEFAULT_FIELD = RawIndicatorFieldId.Feed;
export interface IndicatorsBarChartWrapperProps {
/**
* From and to values received from the KQL bar and passed down to the hook to query data.
*/
timeRange?: TimeRange;
/**
* List of fields coming from the Security Solution sourcerer data view, passed down to the {@link IndicatorFieldSelector} to populate the dropdown.
*/
indexPattern: SecuritySolutionDataViewBase;
}
/**
* Displays the {@link IndicatorsBarChart} and {@link IndicatorsFieldSelector} components,
* and handles retrieving aggregated indicator data.
*/
export const IndicatorsBarChartWrapper = memo<IndicatorsBarChartWrapperProps>(
({ timeRange, indexPattern }) => {
const { dateRange, indicators, onFieldChange } = useAggregatedIndicators({ timeRange });
const { dateRange, indicators, selectedField, onFieldChange } = useAggregatedIndicators({
timeRange,
});
return (
<>
@ -47,7 +59,11 @@ export const IndicatorsBarChartWrapper = memo<IndicatorsBarChartWrapperProps>(
/>
</EuiFlexItem>
</EuiFlexGroup>
{timeRange ? <IndicatorsBarChart indicators={indicators} dateRange={dateRange} /> : <></>}
{timeRange ? (
<IndicatorsBarChart indicators={indicators} dateRange={dateRange} field={selectedField} />
) : (
<></>
)}
</>
);
}

View file

@ -33,8 +33,7 @@ export const Default: Story<void> = () => {
return (
<IndicatorsFieldSelector
indexPattern={mockIndexPattern}
// eslint-disable-next-line no-console
valueChange={(value: string) => console.log(value)}
valueChange={(value: string) => window.alert(`${value} selected`)}
/>
);
};
@ -43,19 +42,12 @@ export const WithDefaultValue: Story<void> = () => {
return (
<IndicatorsFieldSelector
indexPattern={mockIndexPattern}
// eslint-disable-next-line no-console
valueChange={(value: string) => console.log(value)}
valueChange={(value: string) => window.alert(`${value} selected`)}
defaultStackByValue={RawIndicatorFieldId.LastSeen}
/>
);
};
export const NoData: Story<void> = () => {
return (
<IndicatorsFieldSelector
indexPattern={{ fields: [] } as any}
// eslint-disable-next-line no-console
valueChange={(value: string) => console.log(value)}
/>
);
return <IndicatorsFieldSelector indexPattern={{ fields: [] } as any} valueChange={() => {}} />;
};

View file

@ -7,9 +7,11 @@
import React from 'react';
import { Story } from '@storybook/react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../../common/constants';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
import { mockUiSettingsService } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { IndicatorsFlyout } from './indicators_flyout';
@ -18,47 +20,34 @@ export default {
title: 'IndicatorsFlyout',
};
const mockIndicator: Indicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'threat.indicator.ip': 'ip',
'threat.indicator.first_seen': 'date',
};
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
uiSettings: mockUiSettingsService(),
timelines: mockKibanaTimelinesService,
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
export const Default: Story<void> = () => {
const mockIndicator: Indicator = generateMockIndicator();
const mockFieldTypesMap = generateFieldTypeMap();
return (
<KibanaReactContext.Provider>
<IndicatorsFlyout
indicator={mockIndicator}
fieldTypesMap={mockFieldTypesMap}
// eslint-disable-next-line no-console
closeFlyout={() => console.log('closing')}
closeFlyout={() => window.alert('Closing flyout')}
/>
</KibanaReactContext.Provider>
);
};
export const EmtpyIndicator: Story<void> = () => {
export const EmptyIndicator: Story<void> = () => {
return (
<KibanaReactContext.Provider>
<IndicatorsFlyout
indicator={{ fields: {} } as Indicator}
fieldTypesMap={{}}
// eslint-disable-next-line no-console
closeFlyout={() => console.log('closing')}
closeFlyout={() => window.alert('Closing flyout')}
/>
</KibanaReactContext.Provider>
);

View file

@ -11,16 +11,14 @@ import { IndicatorsFlyout, SUBTITLE_TEST_ID, TITLE_TEST_ID } from './indicators_
import { generateMockIndicator, RawIndicatorFieldId } from '../../../../../common/types/indicator';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { dateFormatter } from '../../../../common/utils/dates';
import { mockUiSetting } from '../../../../common/mocks/mock_kibana_ui_setting';
import { mockUiSetting } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { TestProvidersComponent } from '../../../../common/mocks/test_providers';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
import { unwrapValue } from '../../lib/unwrap_value';
import { displayValue } from '../../lib/display_value';
const mockIndicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'@timestamp': 'date',
'threat.feed.name': 'string',
};
const mockFieldTypesMap = generateFieldTypeMap();
describe('<IndicatorsFlyout />', () => {
it('should render ioc id in title and first_seen in subtitle', () => {

View file

@ -36,11 +36,23 @@ const enum TAB_IDS {
}
export interface IndicatorsFlyoutProps {
/**
* Indicator passed down to the different tabs (table and json views).
*/
indicator: Indicator;
/**
* Object mapping each field with their type to ease display in the {@link IndicatorsFlyoutTable} component.
*/
fieldTypesMap: { [id: string]: string };
/**
* Event to close flyout (used by {@link EuiFlyout}).
*/
closeFlyout: () => void;
}
/**
* Leverages the {@link EuiFlyout} from the @elastic/eui library to dhow the details of a specific {@link Indicator}.
*/
export const IndicatorsFlyout: VFC<IndicatorsFlyoutProps> = ({
indicator,
fieldTypesMap,

View file

@ -15,9 +15,9 @@ export default {
title: 'IndicatorsFlyoutJson',
};
const mockIndicator: Indicator = generateMockIndicator();
export const Default: Story<void> = () => {
const mockIndicator: Indicator = generateMockIndicator();
return <IndicatorsFlyoutJson indicator={mockIndicator} />;
};

View file

@ -14,9 +14,16 @@ export const EMPTY_PROMPT_TEST_ID = 'tiFlyoutJsonEmptyPrompt';
export const CODE_BLOCK_TEST_ID = 'tiFlyoutJsonCodeBlock';
export interface IndicatorsFlyoutJsonProps {
/**
* Indicator to display in json format.
*/
indicator: Indicator;
}
/**
* Displays all the properties and values of an {@link Indicator} in json view,
* using the {@link EuiCodeBlock} from the @elastic/eui library.
*/
export const IndicatorsFlyoutJson: VFC<IndicatorsFlyoutJsonProps> = ({ indicator }) => {
return Object.keys(indicator).length === 0 ? (
<EuiEmptyPrompt

View file

@ -9,36 +9,26 @@ import React from 'react';
import { Story } from '@storybook/react';
import { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../../common/constants';
import { IndicatorsFlyoutTable } from './indicators_flyout_table';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
import { mockUiSettingsService } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { IndicatorsFlyoutTable } from './indicators_flyout_table';
export default {
component: IndicatorsFlyoutTable,
title: 'IndicatorsFlyoutTable',
};
const mockIndicator: Indicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'threat.indicator.ip': 'ip',
'threat.indicator.first_seen': 'date',
};
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
export const Default: Story<void> = () => {
const mockIndicator: Indicator = generateMockIndicator();
const mockFieldTypesMap = generateFieldTypeMap();
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService(),
timelines: mockKibanaTimelinesService,
} as unknown as CoreStart);
return (
<KibanaReactContext.Provider>
<IndicatorsFlyoutTable indicator={mockIndicator} fieldTypesMap={mockFieldTypesMap} />
@ -48,11 +38,6 @@ export const Default: Story<void> = () => {
export const EmptyIndicator: Story<void> = () => {
return (
<KibanaReactContext.Provider>
<IndicatorsFlyoutTable
indicator={{ fields: {} } as unknown as Indicator}
fieldTypesMap={{}}
/>
</KibanaReactContext.Provider>
<IndicatorsFlyoutTable indicator={{ fields: {} } as unknown as Indicator} fieldTypesMap={{}} />
);
};

View file

@ -13,6 +13,7 @@ import {
Indicator,
RawIndicatorFieldId,
} from '../../../../../common/types/indicator';
import { generateFieldTypeMap } from '../../../../common/mocks/mock_field_type_map';
import {
EMPTY_PROMPT_TEST_ID,
IndicatorsFlyoutTable,
@ -22,10 +23,7 @@ import { unwrapValue } from '../../lib/unwrap_value';
import { displayValue } from '../../lib/display_value';
const mockIndicator: Indicator = generateMockIndicator();
const mockFieldTypesMap: { [id: string]: string } = {
'@timestamp': 'date',
'threat.feed.name': 'string',
};
const mockFieldTypesMap = generateFieldTypeMap();
describe('<IndicatorsFlyoutTable />', () => {
it('should render fields and values in table', () => {

View file

@ -9,10 +9,12 @@ import { EuiEmptyPrompt, EuiInMemoryTable } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { VFC } from 'react';
import { Indicator } from '../../../../../common/types/indicator';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { IndicatorField } from '../indicator_field/indicator_field';
export const EMPTY_PROMPT_TEST_ID = 'tiFlyoutTableEmptyPrompt';
export const TABLE_TEST_ID = 'tiFlyoutTableMemoryTable';
export const TIMELINE_BUTTON_TEST_ID = 'tiFlyoutTableRowTimelineButton';
const search = {
box: {
@ -22,16 +24,42 @@ const search = {
};
export interface IndicatorsFlyoutTableProps {
/**
* Indicator to display in table view.
*/
indicator: Indicator;
/**
* Object mapping each field with their type to ease display in the {@link IndicatorField} component.
*/
fieldTypesMap: { [id: string]: string };
}
/**
* Displays all the properties and values of an {@link Indicator} in table view,
* using the {@link EuiInMemoryTable} from the @elastic/eui library.
*/
export const IndicatorsFlyoutTable: VFC<IndicatorsFlyoutTableProps> = ({
indicator,
fieldTypesMap,
}) => {
const items: string[] = Object.keys(indicator.fields);
const columns = [
{
name: (
<FormattedMessage
id="xpack.threatIntelligence.indicator.flyoutTable.actionsColumnLabel"
defaultMessage="Actions"
/>
),
actions: [
{
render: (field: string) => (
<AddToTimeline data={indicator} field={field} testId={TIMELINE_BUTTON_TEST_ID} />
),
},
],
width: '72px',
},
{
name: (
<FormattedMessage

View file

@ -5,18 +5,11 @@
* 2.0.
*/
import { EuiText } from '@elastic/eui';
import { BrowserField } from '@kbn/triggers-actions-ui-plugin/public/application/sections/field_browser/types';
import React from 'react';
import { useMemo } from 'react';
import { RawIndicatorFieldId } from '../../../../../../common/types/indicator';
import { EuiDataGridColumn, EuiText } from '@elastic/eui';
import { BrowserField } from '@kbn/triggers-actions-ui-plugin/public/application/sections/field_browser/types';
import { IndicatorsFieldBrowser } from '../../indicators_field_browser';
import { ComputedIndicatorFieldId } from '../cell_renderer';
export interface Column {
id: RawIndicatorFieldId | ComputedIndicatorFieldId;
displayAsText: string;
}
export const useToolbarOptions = ({
browserFields,
@ -31,7 +24,7 @@ export const useToolbarOptions = ({
start: number;
end: number;
indicatorCount: number;
columns: Column[];
columns: EuiDataGridColumn[];
onResetColumns: () => void;
onToggleColumn: (columnId: string) => void;
}) =>

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import { EuiText } from '@elastic/eui';
import { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../../common/constants';
import { mockTriggersActionsUiService } from '../../../../common/mocks/mock_kibana_triggers_actions_ui_service';
import { mockUiSettingsService } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { IndicatorsTable } from './indicators_table';
@ -19,34 +20,19 @@ export default {
title: 'IndicatorsTable',
};
const indicatorsFixture: Indicator[] = Array(10).fill(generateMockIndicator());
const mockIndexPattern: DataView = undefined as unknown as DataView;
const stub = () => void 0;
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
triggersActionsUi: {
getFieldBrowser: () => (
<EuiText style={{ display: 'inline' }} size="xs">
Fields
</EuiText>
),
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
export function WithIndicators() {
const indicatorsFixture: Indicator[] = Array(10).fill(generateMockIndicator());
const KibanaReactContext = createKibanaReactContext({
uiSettings: mockUiSettingsService(),
timelines: mockKibanaTimelinesService,
triggersActionsUi: mockTriggersActionsUiService,
} as unknown as CoreStart);
return (
<KibanaReactContext.Provider>
<IndicatorsTable

View file

@ -6,20 +6,29 @@
*/
import React, { VFC, useState, useMemo, useEffect, useCallback } from 'react';
import { EuiDataGrid, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import {
EuiDataGrid,
EuiDataGridColumnCellActionProps,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiDataGridColumn } from '@elastic/eui/src/components/datagrid/data_grid_types';
import { BrowserFields, SecuritySolutionDataViewBase } from '../../../../types';
import { Indicator, RawIndicatorFieldId } from '../../../../../common/types/indicator';
import { cellRendererFactory, ComputedIndicatorFieldId } from './cell_renderer';
import { EmptyState } from '../../../../components/empty_state';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { IndicatorsTableContext, IndicatorsTableContextValue } from './context';
import { IndicatorsFlyout } from '../indicators_flyout/indicators_flyout';
import { Pagination } from '../../hooks/use_indicators';
import { Column, useToolbarOptions } from './hooks/use_toolbar_options';
import { useToolbarOptions } from './hooks/use_toolbar_options';
const defaultColumns: Column[] = [
const defaultColumns: EuiDataGridColumn[] = [
{
id: RawIndicatorFieldId.TimeStamp,
displayAsText: i18n.translate('xpack.threatIntelligence.indicator.table.timestampColumnTitle', {
@ -73,6 +82,7 @@ export interface IndicatorsTableProps {
}
export const TABLE_TEST_ID = 'tiIndicatorsTable';
export const CELL_TIMELINE_BUTTON_TEST_ID = 'tiIndicatorsTableCellTimelineButton';
const gridStyle = {
border: 'horizontal',
@ -91,9 +101,9 @@ export const IndicatorsTable: VFC<IndicatorsTableProps> = ({
indexPattern,
browserFields,
}) => {
const [columns, setColumns] = useState<Column[]>(defaultColumns);
const [columns, setColumns] = useState<EuiDataGridColumn[]>(defaultColumns);
const [visibleColumns, setVisibleColumns] = useState<Array<Column['id']>>(
const [visibleColumns, setVisibleColumns] = useState<Array<EuiDataGridColumn['id']>>(
columns.map((column) => column.id)
);
@ -152,8 +162,8 @@ export const IndicatorsTable: VFC<IndicatorsTableProps> = ({
const handleToggleColumn = useCallback((columnId: string) => {
setColumns((currentColumns) => {
const columnsMatchingId = ({ id }: Column) => id === columnId;
const columnsNotMatchingId = (column: Column) => !columnsMatchingId(column);
const columnsMatchingId = ({ id }: EuiDataGridColumn) => id === columnId;
const columnsNotMatchingId = (column: EuiDataGridColumn) => !columnsMatchingId(column);
const enabled = Boolean(currentColumns.find(columnsMatchingId));
@ -174,6 +184,22 @@ export const IndicatorsTable: VFC<IndicatorsTableProps> = ({
setVisibleColumns(columns.map(({ id }) => id));
}, [columns]);
useMemo(() => {
columns.map(
(col: EuiDataGridColumn) =>
(col.cellActions = [
({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => (
<AddToTimeline
data={indicators[rowIndex % pagination.pageSize]}
field={columnId}
component={Component}
testId={CELL_TIMELINE_BUTTON_TEST_ID}
/>
),
])
);
}, [columns, indicators, pagination]);
const toolbarOptions = useToolbarOptions({
browserFields,
start,

View file

@ -7,9 +7,8 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../../common/constants';
import { mockUiSettingsService } from '../../../../common/mocks/mock_kibana_ui_settings_service';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { OpenIndicatorFlyoutButton } from './open_indicator_flyout_button';
@ -23,20 +22,7 @@ export default {
const mockIndicator: Indicator = generateMockIndicator();
const coreMock = {
uiSettings: {
get: (key: string) => {
const settings = {
[DEFAULT_DATE_FORMAT]: '',
[DEFAULT_DATE_FORMAT_TZ]: 'UTC',
};
// @ts-expect-error
return settings[key];
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
const KibanaReactContext = createKibanaReactContext({ uiSettings: mockUiSettingsService() });
const Template: ComponentStory<typeof OpenIndicatorFlyoutButton> = (args) => {
return (

View file

@ -13,11 +13,23 @@ import { Indicator } from '../../../../../common/types/indicator';
export const BUTTON_TEST_ID = 'tiToggleIndicatorFlyoutButton';
export interface OpenIndicatorFlyoutButtonProps {
/**
* {@link Indicator} passed to the flyout component.
*/
indicator: Indicator;
/**
* Method called by the onClick event to open/close the flyout.
*/
onOpen: (indicator: Indicator) => void;
/**
* Boolean used to change the color and type of icon depending on the state of the flyout.
*/
isOpen: boolean;
}
/**
* Button added to the actions column of the indicators table to open/close the IndicatorFlyout component.
*/
export const OpenIndicatorFlyoutButton: VFC<OpenIndicatorFlyoutButtonProps> = ({
indicator,
onOpen,
@ -31,18 +43,16 @@ export const OpenIndicatorFlyoutButton: VFC<OpenIndicatorFlyoutButtonProps> = ({
);
return (
<>
<EuiToolTip content={buttonLabel} delay="long">
<EuiButtonIcon
data-test-subj={BUTTON_TEST_ID}
color={isOpen ? 'primary' : 'text'}
iconType={isOpen ? 'minimize' : 'expand'}
isSelected={isOpen}
iconSize="s"
aria-label={buttonLabel}
onClick={() => onOpen(indicator)}
/>
</EuiToolTip>
</>
<EuiToolTip content={buttonLabel} delay="long">
<EuiButtonIcon
data-test-subj={BUTTON_TEST_ID}
color={isOpen ? 'primary' : 'text'}
iconType={isOpen ? 'minimize' : 'expand'}
isSelected={isOpen}
iconSize="s"
aria-label={buttonLabel}
onClick={() => onOpen(indicator)}
/>
</EuiToolTip>
);
};

View file

@ -32,6 +32,7 @@ export interface UseAggregatedIndicatorsValue {
indicators: ChartSeries[];
onFieldChange: (field: string) => void;
dateRange: TimeRangeBounds;
selectedField: string;
}
export interface Aggregation {
@ -209,5 +210,6 @@ export const useAggregatedIndicators = ({
dateRange,
indicators,
onFieldChange,
selectedField: field,
};
};

View file

@ -40,6 +40,7 @@ describe('<IndicatorsPage />', () => {
).mockReturnValue({
dateRange: { min: moment(), max: moment() },
indicators: [],
selectedField: '',
onFieldChange: () => {},
});

View file

@ -10,8 +10,9 @@ import {
generateMockIndicator,
generateMockUrlIndicator,
Indicator,
RawIndicatorFieldId,
} from '../../../../common/types/indicator';
import { displayValue } from './display_value';
import { displayField, displayValue } from './display_value';
type ExpectedIndicatorValue = string | null;
@ -40,3 +41,18 @@ describe('displayValue()', () => {
}
);
});
describe('displayValueField()', () => {
it('should return correct RawIndicatorFieldId for valid field', () => {
const mockIndicator = generateMockIndicator();
const result = displayField(mockIndicator);
expect(result).toEqual(RawIndicatorFieldId.Ip);
});
it('should return null for invalid field', () => {
const mockIndicator = generateMockIndicator();
mockIndicator.fields['threat.indicator.type'] = ['abc'];
const result = displayField(mockIndicator);
expect(result).toBeUndefined();
});
});

View file

@ -14,7 +14,7 @@ export type IndicatorTypePredicate = (indicatorType: string | null) => boolean;
type MapperRule = [predicate: IndicatorTypePredicate, extract: IndicatorValueExtractor];
/**
* Predicates to help identify identicator by type
* Predicates to help identify indicator by type
*/
const isIpIndicator: IndicatorTypePredicate = (indicatorType) =>
!!indicatorType && ['ipv4-addr', 'ipv6-addr'].includes(indicatorType);
@ -56,6 +56,25 @@ const findMappingRule = (indicatorType: string | null): IndicatorValueExtractor
*/
const rules: Record<string, IndicatorValueExtractor> = {};
/**
* Mapping between the indicator type and the {@link RawIndicatorFieldId}.
*/
const indicatorTypeToField: { [id: string]: RawIndicatorFieldId } = {
file: RawIndicatorFieldId.FileSha256,
'ipv4-addr': RawIndicatorFieldId.Ip,
'ipv6-addr': RawIndicatorFieldId.Ip,
url: RawIndicatorFieldId.UrlFull,
};
/**
* Find and return indicator display value field
*/
export const displayField = (indicator: Indicator): string | null => {
const indicatorType = (unwrapValue(indicator, RawIndicatorFieldId.Type) || '').toLowerCase();
return indicatorTypeToField[indicatorType];
};
/**
* Find and return indicator display value, if possible
*/

View file

@ -0,0 +1,407 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<AddToTimeline /> should render empty component when calculated value is - 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<AddToTimeline /> should render empty component when data is - 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<AddToTimeline /> should render empty component when field doesn't exist in data 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<AddToTimeline /> should render empty component when field exist in data but isn't supported 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<AddToTimeline /> should render timeline button when Indicator data 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<AddToTimeline /> should render timeline button when string data 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<span
data-test-subj="test-add-to-timeline"
>
Add To Timeline
</span>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Story } from '@storybook/react';
import { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { AddToTimeline } from './add_to_timeline';
export default {
component: AddToTimeline,
title: 'AddToTimeline',
};
const mockField: string = 'threat.indicator.ip';
const KibanaReactContext = createKibanaReactContext({
timelines: mockKibanaTimelinesService,
} as unknown as CoreStart);
export const Default: Story<void> = () => {
const mockData: Indicator = generateMockIndicator();
return (
<KibanaReactContext.Provider>
<AddToTimeline data={mockData} field={mockField} />
</KibanaReactContext.Provider>
);
};
export const WithIndicator: Story<void> = () => {
const mockData: string = 'ip';
return (
<KibanaReactContext.Provider>
<AddToTimeline data={mockData} field={mockField} />
</KibanaReactContext.Provider>
);
};
export const EmptyValue: Story<void> = () => (
<KibanaReactContext.Provider>
<AddToTimeline data={EMPTY_VALUE} field={mockField} />
</KibanaReactContext.Provider>
);

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { AddToTimeline } from './add_to_timeline';
import { TestProvidersComponent } from '../../../../common/mocks/test_providers';
describe('<AddToTimeline />', () => {
it('should render timeline button when Indicator data', () => {
const mockField: string = 'threat.indicator.ip';
const mockData: Indicator = generateMockIndicator();
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
it('should render timeline button when string data', () => {
const mockField: string = 'threat.indicator.ip';
const mockString: string = 'ip';
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockString} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
it(`should render empty component when field doesn't exist in data`, () => {
const mockField: string = 'abc';
const mockData: Indicator = generateMockIndicator();
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
it(`should render empty component when field exist in data but isn't supported`, () => {
const mockField: string = 'abc';
const mockData: Indicator = generateMockIndicator();
mockData.fields['threat.indicator.type'] = ['abc'];
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
it(`should render empty component when calculated value is ${EMPTY_VALUE}`, () => {
const mockField: string = 'threat.indicator.first_seen';
const mockData: Indicator = generateMockIndicator();
mockData.fields['threat.indicator.first_seen'] = [''];
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
it(`should render empty component when data is ${EMPTY_VALUE}`, () => {
const mockField: string = 'threat.indicator.ip';
const mockData = EMPTY_VALUE;
const component = render(
<TestProvidersComponent>
<AddToTimeline field={mockField} data={mockData} />
</TestProvidersComponent>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,97 @@
/*
* 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, { VFC } from 'react';
import { DataProvider, QueryOperator } from '@kbn/timelines-plugin/common';
import { AddToTimelineButtonProps } from '@kbn/timelines-plugin/public';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui/src/components/button';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { displayField, displayValue } from '../../../indicators/lib/display_value';
import { ComputedIndicatorFieldId } from '../../../indicators/components/indicators_table/cell_renderer';
import { useKibana } from '../../../../hooks/use_kibana';
import { unwrapValue } from '../../../indicators/lib/unwrap_value';
import { Indicator, RawIndicatorFieldId } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
export interface AddToTimelineProps {
/**
* Value passed to the timeline. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator | string;
/**
* Value passed to the timeline.
*/
field: string;
/**
* Only used with `EuiDataGrid` (see {@link AddToTimelineButtonProps}).
*/
component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
/**
* Used as `data-test-subj` value for e2e tests.
*/
testId?: string;
}
/**
* Add to timeline button, used in many places throughout the TI plugin.
* Support being passed a {@link Indicator} or a string, can be used in a `EuiDataGrid` or as a normal button.
* Leverages the built-in functionality retrieves from the timeLineService (see ThreatIntelligenceSecuritySolutionContext in x-pack/plugins/threat_intelligence/public/types.ts)
* Clicking on the button will add a key-value pair to an Untitled timeline.
*
* @returns add to timeline button or an empty component.
*/
export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, component, testId }) => {
const styles = useStyles();
const addToTimelineButton =
useKibana().services.timelines.getHoverActions().getAddToTimelineButton;
let value: string | null;
if (typeof data === 'string') {
value = data;
} else if (field === ComputedIndicatorFieldId.DisplayValue) {
field = displayField(data) || '';
value = displayValue(data);
} else {
value = unwrapValue(data, field as RawIndicatorFieldId);
}
if (!value || value === EMPTY_VALUE || !field) {
return <></>;
}
const operator = ':' as QueryOperator;
const dataProvider: DataProvider[] = [
{
and: [],
enabled: true,
id: `timeline-indicator-${field}-${value}`,
name: value,
excluded: false,
kqlQuery: '',
queryMatch: {
field,
value,
operator,
},
},
];
const addToTimelineProps: AddToTimelineButtonProps = {
dataProvider,
field,
ownFocus: false,
};
if (component) addToTimelineProps.Component = component;
return (
<div data-test-subj={testId} css={styles.button}>
{addToTimelineButton(addToTimelineProps)}
</div>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './add_to_timeline';

View file

@ -5,11 +5,14 @@
* 2.0.
*/
export function mockUiSetting(key: string): string | undefined {
if (key === 'dateFormat') {
return 'MMM D, YYYY @ HH:mm:ss.SSS';
}
if (key === 'dateFormat:tz') {
return 'America/New_York';
}
}
import { CSSObject } from '@emotion/react';
export const useStyles = () => {
const button: CSSObject = {
display: 'inline-flex',
};
return {
button,
};
};

View file

@ -14,6 +14,7 @@ import {
FieldSpec,
} from '@kbn/data-views-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
import { BrowserField } from '@kbn/triggers-actions-ui-plugin/public/application/sections/field_browser/types';
import { DataViewBase } from '@kbn/es-query';
@ -40,6 +41,7 @@ export type Services = {
storage: Storage;
dataViews: DataViewsPublicPluginStart;
triggersActionsUi: TriggersActionsStart;
timelines: TimelinesUIStart;
} & CoreStart;
export interface LicenseAware {
@ -66,5 +68,8 @@ export interface SecuritySolutionPluginContext {
* Get the user's license to drive the Threat Intelligence plugin's visibility.
*/
licenseService: LicenseAware;
/**
* Gets Security Solution shared information like browerFields, indexPattern and selectedPatterns in DataView
*/
sourcererDataView: SourcererDataView;
}

View file

@ -14,6 +14,7 @@
"../../../typings/**/*"
],
"references": [
{ "path": "../timelines/tsconfig.json" },
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../../../src/plugins/unified_search/tsconfig.json" },

View file

@ -93,3 +93,5 @@ export function plugin() {
export { StatefulEventContext } from './components/stateful_event_context';
export { TimelineContext } from './components/t_grid/shared';
export type { AddToTimelineButtonProps } from './components/hover_actions/actions/add_to_timeline';