Skip to content

Commit

Permalink
[SIEM] Adds custom tooltip to map for dragging fields to timeline (#4…
Browse files Browse the repository at this point in the history
…6879) (#48357)

## Summary

Resolves #46301, by adding a custom tooltip for the map that enables dragging to the timeline.

##### Features:
* Adds new portal pattern to enable DnD from outside the main react component tree
* Adds `<DraggablePortalContext>` component to enable DnD from within an `EuiPopover`
  * Just wrap `EuiPopover` contents in `<DraggablePortalContext.Provider value={true}></...>` and all child `DefaultDraggable`'s will now function correctly
* Displays netflow renderer within tooltip for line features, w/ draggable src/dest.bytes
* Displays detailed description list within tooltip for point features. Fields include:
  * host.name
  * source/destination.ip
  * source/destination.domain 
  * source/destination.geo.country_iso_code
  * source/destination.as.organization.name
* Retains ability to add filter to KQL bar

![map_custom_tooltips](https://user-images.githubusercontent.com/2946766/66288691-64c74f00-e897-11e9-9845-54e8c9b9c5ab.gif)


### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~
- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~

### For maintainers

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
  • Loading branch information
XavierM authored and spong committed Oct 16, 2019
1 parent 40c73a8 commit c81150b
Show file tree
Hide file tree
Showing 27 changed files with 1,331 additions and 73 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { isEqual } from 'lodash/fp';
import React, { useEffect } from 'react';
import React, { createContext, useContext, useEffect } from 'react';
import {
Draggable,
DraggableProvided,
Expand All @@ -16,6 +16,7 @@ import { connect } from 'react-redux';
import styled, { css } from 'styled-components';
import { ActionCreator } from 'typescript-fsa';

import { EuiPortal } from '@elastic/eui';
import { dragAndDropActions } from '../../store/drag_and_drop';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../timeline/helpers';
Expand All @@ -27,6 +28,9 @@ export const DragEffects = styled.div``;

DragEffects.displayName = 'DragEffects';

export const DraggablePortalContext = createContext<boolean>(false);
export const useDraggablePortalContext = () => useContext(DraggablePortalContext);

const Wrapper = styled.div`
display: inline-block;
max-width: 100%;
Expand Down Expand Up @@ -127,7 +131,7 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>`
${isDragging &&
`
& {
z-index: ${theme.eui.euiZLevel9} !important;
z-index: 9999 !important;
}
`}
`}
Expand Down Expand Up @@ -164,6 +168,8 @@ type Props = OwnProps & DispatchProps;

const DraggableWrapperComponent = React.memo<Props>(
({ dataProvider, registerProvider, render, truncate, unRegisterProvider }) => {
const usePortal = useDraggablePortalContext();

useEffect(() => {
registerProvider!({ provider: dataProvider });
return () => {
Expand All @@ -182,26 +188,28 @@ const DraggableWrapperComponent = React.memo<Props>(
key={getDraggableId(dataProvider.id)}
>
{(provided, snapshot) => (
<ProviderContainer
{...provided.draggableProps}
{...provided.dragHandleProps}
innerRef={provided.innerRef}
data-test-subj="providerContainer"
isDragging={snapshot.isDragging}
style={{
...provided.draggableProps.style,
}}
>
{truncate && !snapshot.isDragging ? (
<TruncatableText data-test-subj="draggable-truncatable-content">
{render(dataProvider, provided, snapshot)}
</TruncatableText>
) : (
<span data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`}>
{render(dataProvider, provided, snapshot)}
</span>
)}
</ProviderContainer>
<ConditionalPortal usePortal={snapshot.isDragging && usePortal}>
<ProviderContainer
{...provided.draggableProps}
{...provided.dragHandleProps}
innerRef={provided.innerRef}
data-test-subj="providerContainer"
isDragging={snapshot.isDragging}
style={{
...provided.draggableProps.style,
}}
>
{truncate && !snapshot.isDragging ? (
<TruncatableText data-test-subj="draggable-truncatable-content">
{render(dataProvider, provided, snapshot)}
</TruncatableText>
) : (
<span data-test-subj={`draggable-content-${dataProvider.queryMatch.field}`}>
{render(dataProvider, provided, snapshot)}
</span>
)}
</ProviderContainer>
</ConditionalPortal>
)}
</Draggable>
{droppableProvided.placeholder}
Expand Down Expand Up @@ -229,3 +237,15 @@ export const DraggableWrapper = connect(
unRegisterProvider: dragAndDropActions.unRegisterProvider,
}
)(DraggableWrapperComponent);

/**
* Conditionally wraps children in an EuiPortal to ensure drag offsets are correct when dragging
* from containers that have css transforms
*
* See: https://github.com/atlassian/react-beautiful-dnd/issues/499
*/
const ConditionalPortal = React.memo<{ children: React.ReactNode; usePortal: boolean }>(
({ children, usePortal }) => (usePortal ? <EuiPortal>{children}</EuiPortal> : <>{children}</>)
);

ConditionalPortal.displayName = 'ConditionalPortal';
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export const mockSourceLayer = {
type: 'ES_SEARCH',
geoField: 'source.geo.location',
filterByMapBounds: false,
tooltipProperties: ['host.name', 'source.ip', 'source.domain', 'source.as.organization.name'],
tooltipProperties: [
'host.name',
'source.ip',
'source.domain',
'source.geo.country_iso_code',
'source.as.organization.name',
],
useTopHits: false,
topHitsTimeField: '@timestamp',
topHitsSize: 1,
Expand Down Expand Up @@ -55,6 +61,7 @@ export const mockDestinationLayer = {
'host.name',
'destination.ip',
'destination.domain',
'destination.geo.country_iso_code',
'destination.as.organization.name',
],
useTopHits: false,
Expand Down Expand Up @@ -92,9 +99,8 @@ export const mockLineLayer = {
sourceGeoField: 'source.geo.location',
destGeoField: 'destination.geo.location',
metrics: [
{ type: 'sum', field: 'source.bytes', label: 'Total Src Bytes' },
{ type: 'sum', field: 'destination.bytes', label: 'Total Dest Bytes' },
{ type: 'count', label: 'Total Documents' },
{ type: 'sum', field: 'source.bytes', label: 'source.bytes' },
{ type: 'sum', field: 'destination.bytes', label: 'destination.bytes' },
],
},
style: {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiSpacer } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { npStart } from 'ui/new_platform';
import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder';
import { createPortalNode, InPortal } from 'react-reverse-portal';

import styled from 'styled-components';
import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy';
Expand All @@ -23,6 +24,7 @@ import { MapEmbeddable, SetQuery } from './types';
import * as i18n from './translations';
import { useStateToaster } from '../toasters';
import { createEmbeddable, displayErrorToast, setupEmbeddablesAPI } from './embedded_map_helpers';
import { MapToolTip } from './map_tool_tip/map_tool_tip';

const EmbeddableWrapper = styled(EuiFlexGroup)`
position: relative;
Expand Down Expand Up @@ -53,6 +55,12 @@ export const EmbeddedMap = React.memo<EmbeddedMapProps>(
const [loadingKibanaIndexPatterns, kibanaIndexPatterns] = useIndexPatterns();
const [siemDefaultIndices] = useKibanaUiSetting(DEFAULT_INDEX_KEY);

// This portalNode provided by react-reverse-portal allows us re-parent the MapToolTip within our
// own component tree instead of the embeddables (default). This is necessary to have access to
// the Redux store, theme provider, etc, which is required to register and un-register the draggable
// Search InPortal/OutPortal for implementation touch points
const portalNode = React.useMemo(() => createPortalNode(), []);

// Initial Load useEffect
useEffect(() => {
let isSubscribed = true;
Expand Down Expand Up @@ -84,7 +92,8 @@ export const EmbeddedMap = React.memo<EmbeddedMapProps>(
queryExpression,
startDate,
endDate,
setQuery
setQuery,
portalNode
);
if (isSubscribed) {
setEmbeddable(embeddableObject);
Expand Down Expand Up @@ -129,6 +138,9 @@ export const EmbeddedMap = React.memo<EmbeddedMapProps>(

return isError ? null : (
<>
<InPortal node={portalNode}>
<MapToolTip />
</InPortal>
<EmbeddableWrapper>
{embeddable != null ? (
<EmbeddablePanel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { createEmbeddable, displayErrorToast, setupEmbeddablesAPI } from './embedded_map_helpers';
import { npStart } from 'ui/new_platform';
import { createPortalNode } from 'react-reverse-portal';

jest.mock('ui/new_platform');
jest.mock('../../lib/settings/use_kibana_ui_setting');
Expand Down Expand Up @@ -60,13 +61,13 @@ describe('embedded_map_helpers', () => {
describe('createEmbeddable', () => {
test('attaches refresh action', async () => {
const setQueryMock = jest.fn();
await createEmbeddable([], '', 0, 0, setQueryMock);
await createEmbeddable([], '', 0, 0, setQueryMock, createPortalNode());
expect(setQueryMock).toHaveBeenCalledTimes(1);
});

test('attaches refresh action with correct reference', async () => {
const setQueryMock = jest.fn(({ id, inspect, loading, refetch }) => refetch);
const embeddable = await createEmbeddable([], '', 0, 0, setQueryMock);
const embeddable = await createEmbeddable([], '', 0, 0, setQueryMock, createPortalNode());
expect(setQueryMock.mock.calls[0][0].refetch).not.toBe(embeddable.reload);
setQueryMock.mock.results[0].value();
expect(embeddable.reload).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
*/

import uuid from 'uuid';
import React from 'react';
import { npStart } from 'ui/new_platform';
import { OutPortal, PortalNode } from 'react-reverse-portal';
import { ActionToaster, AppToast } from '../toasters';
import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy';
import {
Expand All @@ -19,7 +21,7 @@ import {
APPLY_SIEM_FILTER_ACTION_ID,
ApplySiemFilterAction,
} from './actions/apply_siem_filter_action';
import { IndexPatternMapping, MapEmbeddable, SetQuery } from './types';
import { IndexPatternMapping, MapEmbeddable, RenderTooltipContentParams, SetQuery } from './types';
import { getLayerList } from './map_config';
// @ts-ignore Missing type defs as maps moves to Typescript
import { MAP_SAVED_OBJECT_TYPE } from '../../../../maps/common/constants';
Expand Down Expand Up @@ -88,6 +90,7 @@ export const setupEmbeddablesAPI = (
* @param startDate
* @param endDate
* @param setQuery function as provided by the GlobalTime component for reacting to refresh
* @param portalNode wrapper for MapToolTip so it is not rendered in the embeddables component tree
*
* @throws Error if EmbeddableFactory does not exist
*/
Expand All @@ -96,7 +99,8 @@ export const createEmbeddable = async (
queryExpression: string,
startDate: number,
endDate: number,
setQuery: SetQuery
setQuery: SetQuery,
portalNode: PortalNode
): Promise<MapEmbeddable> => {
const factory = start.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE);

Expand All @@ -121,8 +125,34 @@ export const createEmbeddable = async (
mapCenter: { lon: -1.05469, lat: 15.96133, zoom: 1 },
};

const renderTooltipContent = ({
addFilters,
closeTooltip,
features,
isLocked,
getLayerName,
loadFeatureProperties,
loadFeatureGeometry,
}: RenderTooltipContentParams) => {
const props = {
addFilters,
closeTooltip,
features,
isLocked,
getLayerName,
loadFeatureProperties,
loadFeatureGeometry,
};
return <OutPortal node={portalNode} {...props} />;
};

// @ts-ignore method added in https://github.com/elastic/kibana/pull/43878
const embeddableObject = await factory.createFromState(state, input);
const embeddableObject = await factory.createFromState(
state,
input,
undefined,
renderTooltipContent
);

// Wire up to app refresh action
setQuery({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@

import uuid from 'uuid';
import { IndexPatternMapping } from './types';
import * as i18n from './translations';

// Update source/destination field mappings to modify what fields will be returned to map tooltip
const sourceFieldMappings: Record<string, string> = {
'host.name': i18n.HOST,
'source.ip': i18n.SOURCE_IP,
'source.domain': i18n.SOURCE_DOMAIN,
'source.geo.country_iso_code': i18n.LOCATION,
'source.as.organization.name': i18n.ASN,
};
const destinationFieldMappings: Record<string, string> = {
'host.name': i18n.HOST,
'destination.ip': i18n.DESTINATION_IP,
'destination.domain': i18n.DESTINATION_DOMAIN,
'destination.geo.country_iso_code': i18n.LOCATION,
'destination.as.organization.name': i18n.ASN,
};

// Mapping of field -> display name for use within map tooltip
export const sourceDestinationFieldMappings: Record<string, string> = {
...sourceFieldMappings,
...destinationFieldMappings,
};

// Field names of LineLayer props returned from Maps API
export const SUM_OF_SOURCE_BYTES = 'sum_of_source.bytes';
export const SUM_OF_DESTINATION_BYTES = 'sum_of_destination.bytes';

/**
* Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source,
Expand Down Expand Up @@ -51,7 +78,7 @@ export const getSourceLayer = (indexPatternTitle: string, indexPatternId: string
type: 'ES_SEARCH',
geoField: 'source.geo.location',
filterByMapBounds: false,
tooltipProperties: ['host.name', 'source.ip', 'source.domain', 'source.as.organization.name'],
tooltipProperties: Object.keys(sourceFieldMappings),
useTopHits: false,
topHitsTimeField: '@timestamp',
topHitsSize: 1,
Expand All @@ -69,7 +96,7 @@ export const getSourceLayer = (indexPatternTitle: string, indexPatternId: string
},
},
id: uuid.v4(),
label: `${indexPatternTitle} | Source Point`,
label: `${indexPatternTitle} | ${i18n.SOURCE_LAYER}`,
minZoom: 0,
maxZoom: 24,
alpha: 0.75,
Expand All @@ -93,12 +120,7 @@ export const getDestinationLayer = (indexPatternTitle: string, indexPatternId: s
type: 'ES_SEARCH',
geoField: 'destination.geo.location',
filterByMapBounds: true,
tooltipProperties: [
'host.name',
'destination.ip',
'destination.domain',
'destination.as.organization.name',
],
tooltipProperties: Object.keys(destinationFieldMappings),
useTopHits: false,
topHitsTimeField: '@timestamp',
topHitsSize: 1,
Expand All @@ -116,7 +138,7 @@ export const getDestinationLayer = (indexPatternTitle: string, indexPatternId: s
},
},
id: uuid.v4(),
label: `${indexPatternTitle} | Destination Point`,
label: `${indexPatternTitle} | ${i18n.DESTINATION_LAYER}`,
minZoom: 0,
maxZoom: 24,
alpha: 0.75,
Expand All @@ -141,9 +163,8 @@ export const getLineLayer = (indexPatternTitle: string, indexPatternId: string)
sourceGeoField: 'source.geo.location',
destGeoField: 'destination.geo.location',
metrics: [
{ type: 'sum', field: 'source.bytes', label: 'Total Src Bytes' },
{ type: 'sum', field: 'destination.bytes', label: 'Total Dest Bytes' },
{ type: 'count', label: 'Total Documents' },
{ type: 'sum', field: 'source.bytes', label: 'source.bytes' },
{ type: 'sum', field: 'destination.bytes', label: 'destination.bytes' },
],
},
style: {
Expand Down Expand Up @@ -172,7 +193,7 @@ export const getLineLayer = (indexPatternTitle: string, indexPatternId: string)
},
},
id: uuid.v4(),
label: `${indexPatternTitle} | Line`,
label: `${indexPatternTitle} | ${i18n.LINE_LAYER}`,
minZoom: 0,
maxZoom: 24,
alpha: 1,
Expand Down
Loading

0 comments on commit c81150b

Please sign in to comment.