[SIEM] Adds custom tooltip to map for dragging fields to timeline (#46879)

## Summary

Resolves https://github.com/elastic/kibana/issues/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)
This commit is contained in:
Garrett Spong 2019-10-08 11:21:47 -06:00 committed by GitHub
parent 1947608378
commit 0461a1fefe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1331 additions and 73 deletions

View file

@ -5,7 +5,7 @@
*/
import { isEqual } from 'lodash/fp';
import React, { useEffect } from 'react';
import React, { createContext, useContext, useEffect } from 'react';
import {
Draggable,
DraggableProvided,
@ -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';
@ -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%;
@ -127,7 +131,7 @@ const ProviderContainer = styled.div<{ isDragging: boolean }>`
${isDragging &&
`
& {
z-index: ${theme.eui.euiZLevel9} !important;
z-index: 9999 !important;
}
`}
`}
@ -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 () => {
@ -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}
@ -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';

View file

@ -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,
@ -55,6 +61,7 @@ export const mockDestinationLayer = {
'host.name',
'destination.ip',
'destination.domain',
'destination.geo.country_iso_code',
'destination.as.organization.name',
],
useTopHits: false,
@ -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: {

View file

@ -2,6 +2,11 @@
exports[`EmbeddedMap renders correctly against snapshot 1`] = `
<Fragment>
<InPortal
node={<div />}
>
<MapToolTip />
</InPortal>
<Styled(EuiFlexGroup)>
<Loader
data-test-subj="loading-panel"

View file

@ -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';
@ -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;
@ -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;
@ -84,7 +92,8 @@ export const EmbeddedMap = React.memo<EmbeddedMapProps>(
queryExpression,
startDate,
endDate,
setQuery
setQuery,
portalNode
);
if (isSubscribed) {
setEmbeddable(embeddableObject);
@ -129,6 +138,9 @@ export const EmbeddedMap = React.memo<EmbeddedMapProps>(
return isError ? null : (
<>
<InPortal node={portalNode}>
<MapToolTip />
</InPortal>
<EmbeddableWrapper>
{embeddable != null ? (
<EmbeddablePanel

View file

@ -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');
@ -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);

View file

@ -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 {
@ -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';
@ -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
*/
@ -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);
@ -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({

View file

@ -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,
@ -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,
@ -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,
@ -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,
@ -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,
@ -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: {
@ -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,

View file

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LineToolTipContent renders correctly against snapshot 1`] = `
<EuiFlexGroup
gutterSize="none"
justifyContent="center"
>
<EuiFlexItem>
<Styled(EuiBadge)
color="hollow"
>
<Styled(EuiFlexGroup)
direction="column"
>
<EuiFlexItem
grow={false}
>
Source
</EuiFlexItem>
</Styled(EuiFlexGroup)>
</Styled(EuiBadge)>
</EuiFlexItem>
<SourceDestinationArrows
contextId="contextId"
destinationBytes={
Array [
undefined,
]
}
eventId="map-line-tooltip-contextId"
sourceBytes={
Array [
undefined,
]
}
/>
<EuiFlexItem>
<Styled(EuiBadge)
color="hollow"
>
<Styled(EuiFlexGroup)>
<EuiFlexItem
grow={false}
>
Destination
</EuiFlexItem>
</Styled(EuiFlexGroup)>
</Styled(EuiBadge)>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MapToolTip full component renders correctly against snapshot 1`] = `
<EuiFlexGroup
justifyContent="spaceAround"
>
<EuiFlexItem
grow={false}
>
<EuiLoadingSpinner
size="m"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`MapToolTip placeholder component renders correctly against snapshot 1`] = `
<EuiFlexGroup
justifyContent="spaceAround"
>
<EuiFlexItem
grow={false}
>
<EuiLoadingSpinner
size="m"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PointToolTipContent renders correctly against snapshot 1`] = `
<Component>
<PointToolTipContent
addFilters={[MockFunction]}
closeTooltip={[MockFunction]}
contextId="contextId"
featureProps={
Array [
Object {
"_propertyKey": "host.name",
"_rawValue": "testPropValue",
"getESFilters": [Function],
},
]
}
featurePropsFilters={
Object {
"host.name": Object {},
}
}
/>
</Component>
`;

View file

@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ToolTipFilter renders correctly against snapshot 1`] = `
<Fragment>
<EuiHorizontalRule
margin="s"
/>
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
>
<EuiText
size="xs"
>
1 of 100 features
</EuiText>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
<EuiButtonIcon
aria-label="Next"
color="text"
data-test-subj="previous-feature-button"
disabled={true}
iconType="arrowLeft"
onClick={[MockFunction]}
/>
<EuiButtonIcon
aria-label="Next"
color="text"
data-test-subj="next-feature-button"
disabled={false}
iconType="arrowRight"
onClick={[MockFunction]}
/>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
`;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { LineToolTipContent } from './line_tool_tip_content';
import { FeatureProperty } from '../types';
describe('LineToolTipContent', () => {
const mockFeatureProps: FeatureProperty[] = [
{
_propertyKey: 'host.name',
_rawValue: 'testPropValue',
getESFilters: () => new Promise(resolve => setTimeout(resolve)),
},
];
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<LineToolTipContent contextId={'contextId'} featureProps={mockFeatureProps} />
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { SourceDestinationArrows } from '../../source_destination/source_destination_arrows';
import { SUM_OF_DESTINATION_BYTES, SUM_OF_SOURCE_BYTES } from '../map_config';
import { FeatureProperty } from '../types';
import * as i18n from '../translations';
const FlowBadge = styled(EuiBadge)`
height: 45px;
min-width: 85px;
`;
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
margin: 0 auto;
`;
interface LineToolTipContentProps {
contextId: string;
featureProps: FeatureProperty[];
}
export const LineToolTipContent = React.memo<LineToolTipContentProps>(
({ contextId, featureProps }) => {
const lineProps = featureProps.reduce<Record<string, string>>(
(acc, f) => ({ ...acc, ...{ [f._propertyKey]: f._rawValue } }),
{}
);
return (
<EuiFlexGroup justifyContent="center" gutterSize="none">
<EuiFlexItem>
<FlowBadge color="hollow">
<EuiFlexGroupStyled direction="column">
<EuiFlexItem grow={false}>{i18n.SOURCE}</EuiFlexItem>
</EuiFlexGroupStyled>
</FlowBadge>
</EuiFlexItem>
<SourceDestinationArrows
contextId={contextId}
destinationBytes={[lineProps[SUM_OF_DESTINATION_BYTES]]}
eventId={`map-line-tooltip-${contextId}`}
sourceBytes={[lineProps[SUM_OF_SOURCE_BYTES]]}
/>
<EuiFlexItem>
<FlowBadge color="hollow">
<EuiFlexGroupStyled>
<EuiFlexItem grow={false}>{i18n.DESTINATION}</EuiFlexItem>
</EuiFlexGroupStyled>
</FlowBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
LineToolTipContent.displayName = 'LineToolTipContent';

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { MapToolTip } from './map_tool_tip';
import { MapFeature } from '../types';
describe('MapToolTip', () => {
test('placeholder component renders correctly against snapshot', () => {
const wrapper = shallow(<MapToolTip />);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('full component renders correctly against snapshot', () => {
const addFilters = jest.fn();
const closeTooltip = jest.fn();
const features: MapFeature[] = [
{
id: 1,
layerId: 'layerId',
},
];
const getLayerName = jest.fn();
const loadFeatureProperties = jest.fn();
const loadFeatureGeometry = jest.fn();
const wrapper = shallow(
<MapToolTip
addFilters={addFilters}
closeTooltip={closeTooltip}
features={features}
isLocked={false}
getLayerName={getLayerName}
loadFeatureProperties={loadFeatureProperties}
loadFeatureGeometry={loadFeatureGeometry}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiOutsideClickDetector,
} from '@elastic/eui';
import { FeatureGeometry, FeatureProperty, MapToolTipProps } from '../types';
import { DraggablePortalContext } from '../../drag_and_drop/draggable_wrapper';
import { ToolTipFooter } from './tooltip_footer';
import { LineToolTipContent } from './line_tool_tip_content';
import { PointToolTipContent } from './point_tool_tip_content';
import { Loader } from '../../loader';
import * as i18n from '../translations';
export const MapToolTip = React.memo<MapToolTipProps>(
({
addFilters,
closeTooltip,
features = [],
isLocked,
getLayerName,
loadFeatureProperties,
loadFeatureGeometry,
}) => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isLoadingNextFeature, setIsLoadingNextFeature] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [featureIndex, setFeatureIndex] = useState<number>(0);
const [featureProps, setFeatureProps] = useState<FeatureProperty[]>([]);
const [featurePropsFilters, setFeaturePropsFilters] = useState<Record<string, object>>({});
const [featureGeometry, setFeatureGeometry] = useState<FeatureGeometry | null>(null);
const [, setLayerName] = useState<string>('');
useEffect(() => {
// Early return if component doesn't yet have props -- result of mounting in portal before actual rendering
if (
features.length === 0 ||
getLayerName == null ||
loadFeatureProperties == null ||
loadFeatureGeometry == null
) {
return;
}
// Separate loaders for initial load vs loading next feature to keep tooltip from drastically resizing
if (!isLoadingNextFeature) {
setIsLoading(true);
}
setIsError(false);
const fetchFeatureProps = async () => {
if (features[featureIndex] != null) {
const layerId = features[featureIndex].layerId;
const featureId = features[featureIndex].id;
try {
const featureGeo = loadFeatureGeometry({ layerId, featureId });
const [featureProperties, layerNameString] = await Promise.all([
loadFeatureProperties({ layerId, featureId }),
getLayerName(layerId),
]);
// Fetch ES filters in advance while loader is present to prevent lag when user clicks to add filter
const featurePropsPromises = await Promise.all(
featureProperties.map(property => property.getESFilters())
);
const featurePropsESFilters = featureProperties.reduce(
(acc, property, index) => ({
...acc,
[property._propertyKey]: featurePropsPromises[index],
}),
{}
);
setFeatureProps(featureProperties);
setFeaturePropsFilters(featurePropsESFilters);
setFeatureGeometry(featureGeo);
setLayerName(layerNameString);
} catch (e) {
setIsError(true);
} finally {
setIsLoading(false);
setIsLoadingNextFeature(false);
}
}
};
fetchFeatureProps();
}, [
featureIndex,
features
.map(f => `${f.id}-${f.layerId}`)
.sort()
.join(),
]);
if (isError) {
return (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>{i18n.MAP_TOOL_TIP_ERROR}</EuiFlexItem>
</EuiFlexGroup>
);
}
return isLoading && !isLoadingNextFeature ? (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
) : (
<DraggablePortalContext.Provider value={true}>
<EuiOutsideClickDetector
onOutsideClick={() => {
if (closeTooltip != null) {
closeTooltip();
setFeatureIndex(0);
}
}}
>
<div>
{featureGeometry != null && featureGeometry.type === 'LineString' ? (
<LineToolTipContent
contextId={`${features[featureIndex].layerId}-${features[featureIndex].id}-${featureIndex}`}
featureProps={featureProps}
/>
) : (
<PointToolTipContent
contextId={`${features[featureIndex].layerId}-${features[featureIndex].id}-${featureIndex}`}
featureProps={featureProps}
featurePropsFilters={featurePropsFilters}
addFilters={addFilters}
closeTooltip={closeTooltip}
/>
)}
{features.length > 1 && (
<ToolTipFooter
featureIndex={featureIndex}
totalFeatures={features.length}
previousFeature={() => {
setFeatureIndex(featureIndex - 1);
setIsLoadingNextFeature(true);
}}
nextFeature={() => {
setFeatureIndex(featureIndex + 1);
setIsLoadingNextFeature(true);
}}
/>
)}
{isLoadingNextFeature && <Loader data-test-subj="loading-panel" overlay size="m" />}
</div>
</EuiOutsideClickDetector>
</DraggablePortalContext.Provider>
);
}
);
MapToolTip.displayName = 'MapToolTip';

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { FeatureProperty } from '../types';
import { getRenderedFieldValue, PointToolTipContent } from './point_tool_tip_content';
import { TestProviders } from '../../../mock';
import { getEmptyStringTag } from '../../empty_value';
import { HostDetailsLink, IPDetailsLink } from '../../links';
describe('PointToolTipContent', () => {
const mockFeatureProps: FeatureProperty[] = [
{
_propertyKey: 'host.name',
_rawValue: 'testPropValue',
getESFilters: () => new Promise(resolve => setTimeout(resolve)),
},
];
const mockFeaturePropsFilters: Record<string, object> = { 'host.name': {} };
test('renders correctly against snapshot', () => {
const addFilters = jest.fn();
const closeTooltip = jest.fn();
const wrapper = shallow(
<TestProviders>
<PointToolTipContent
contextId={'contextId'}
featureProps={mockFeatureProps}
featurePropsFilters={mockFeaturePropsFilters}
addFilters={addFilters}
closeTooltip={closeTooltip}
/>
</TestProviders>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('tooltip closes when filter for value hover action is clicked', () => {
const addFilters = jest.fn();
const closeTooltip = jest.fn();
const wrapper = mount(
<TestProviders>
<PointToolTipContent
contextId={'contextId'}
featureProps={mockFeatureProps}
featurePropsFilters={mockFeaturePropsFilters}
addFilters={addFilters}
closeTooltip={closeTooltip}
/>
</TestProviders>
);
wrapper
.find(`[data-test-subj="hover-actions-${mockFeatureProps[0]._propertyKey}"]`)
.first()
.simulate('mouseenter');
wrapper
.find(`[data-test-subj="add-to-filter-${mockFeatureProps[0]._propertyKey}"]`)
.first()
.simulate('click');
expect(closeTooltip).toHaveBeenCalledTimes(1);
expect(addFilters).toHaveBeenCalledTimes(1);
});
describe('#getRenderedFieldValue', () => {
test('it returns empty tag if value is empty', () => {
expect(getRenderedFieldValue('host.name', '')).toStrictEqual(getEmptyStringTag());
});
test('it returns HostDetailsLink if field is host.name', () => {
const value = 'suricata-ross';
expect(getRenderedFieldValue('host.name', value)).toStrictEqual(
<HostDetailsLink hostName={value} />
);
});
test('it returns IPDetailsLink if field is source.ip', () => {
const value = '127.0.0.1';
expect(getRenderedFieldValue('source.ip', value)).toStrictEqual(<IPDetailsLink ip={value} />);
});
test('it returns IPDetailsLink if field is destination.ip', () => {
const value = '127.0.0.1';
expect(getRenderedFieldValue('destination.ip', value)).toStrictEqual(
<IPDetailsLink ip={value} />
);
});
test('it returns nothing if field is not host.name or source/destination.ip', () => {
const value = 'Kramerica.co';
expect(getRenderedFieldValue('destination.domain', value)).toStrictEqual(<>{value}</>);
});
});
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiIcon, EuiToolTip } from '@elastic/eui';
import * as i18n from '../translations';
import { sourceDestinationFieldMappings } from '../map_config';
import { WithHoverActions } from '../../with_hover_actions';
import { HoverActionsContainer } from '../../page/add_to_kql';
import { getEmptyTagValue, getOrEmptyTagFromValue } from '../../empty_value';
import { DescriptionListStyled } from '../../page';
import { FeatureProperty } from '../types';
import { HostDetailsLink, IPDetailsLink } from '../../links';
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
interface PointToolTipContentProps {
contextId: string;
featureProps: FeatureProperty[];
featurePropsFilters: Record<string, object>;
addFilters?(filter: object): void;
closeTooltip?(): void;
}
export const PointToolTipContent = React.memo<PointToolTipContentProps>(
({ contextId, featureProps, featurePropsFilters, addFilters, closeTooltip }) => {
const featureDescriptionListItems = featureProps.map(property => ({
title: sourceDestinationFieldMappings[property._propertyKey],
description: (
<WithHoverActions
data-test-subj={`hover-actions-${property._propertyKey}`}
hoverContent={
<HoverActionsContainer>
<EuiToolTip content={i18n.FILTER_FOR_VALUE}>
<EuiIcon
data-test-subj={`add-to-filter-${property._propertyKey}`}
type="filter"
onClick={() => {
if (closeTooltip != null && addFilters != null) {
closeTooltip();
addFilters(featurePropsFilters[property._propertyKey]);
}
}}
/>
</EuiToolTip>
</HoverActionsContainer>
}
render={() =>
property._rawValue != null ? (
<DefaultFieldRenderer
rowItems={
Array.isArray(property._rawValue) ? property._rawValue : [property._rawValue]
}
attrName={property._propertyKey}
idPrefix={`map-point-tooltip-${contextId}-${property._propertyKey}-${property._rawValue}`}
render={item => getRenderedFieldValue(property._propertyKey, item)}
/>
) : (
getEmptyTagValue()
)
}
/>
),
}));
return <DescriptionListStyled listItems={featureDescriptionListItems} />;
}
);
PointToolTipContent.displayName = 'PointToolTipContent';
export const getRenderedFieldValue = (field: string, value: string) => {
if (value === '') {
return getOrEmptyTagFromValue(value);
} else if (['host.name'].includes(field)) {
return <HostDetailsLink hostName={value} />;
} else if (['source.ip', 'destination.ip'].includes(field)) {
return <IPDetailsLink ip={value} />;
}
return <>{value}</>;
};

View file

@ -0,0 +1,319 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { ToolTipFooter } from './tooltip_footer';
describe('ToolTipFilter', () => {
let nextFeature = jest.fn();
let previousFeature = jest.fn();
beforeEach(() => {
nextFeature = jest.fn();
previousFeature = jest.fn();
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={100}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
describe('Lower bounds', () => {
test('previousButton is disabled when featureIndex is 0', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.prop('disabled')
).toBe(true);
});
test('previousFeature is not called when featureIndex is 0', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.simulate('click');
expect(previousFeature).toHaveBeenCalledTimes(0);
});
test('nextButton is enabled when featureIndex is < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.prop('disabled')
).toBe(false);
});
test('nextFeature is called when featureIndex is < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.simulate('click');
expect(nextFeature).toHaveBeenCalledTimes(1);
});
});
describe('Upper bounds', () => {
test('previousButton is enabled when featureIndex >== totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={4}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.prop('disabled')
).toBe(false);
});
test('previousFunction is called when featureIndex >== totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={4}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.simulate('click');
expect(previousFeature).toHaveBeenCalledTimes(1);
});
test('nextButton is disabled when featureIndex >== totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={4}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.prop('disabled')
).toBe(true);
});
test('nextFunction is not called when featureIndex >== totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={4}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.simulate('click');
expect(nextFeature).toHaveBeenCalledTimes(0);
});
});
describe('Within bounds, single feature', () => {
test('previousButton is not enabled when only a single feature is provided', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={1}
/>
);
expect(
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.prop('disabled')
).toBe(true);
});
test('previousFunction is not called when only a single feature is provided', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={1}
/>
);
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.simulate('click');
expect(previousFeature).toHaveBeenCalledTimes(0);
});
test('nextButton is not enabled when only a single feature is provided', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={1}
/>
);
expect(
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.prop('disabled')
).toBe(true);
});
test('nextFunction is not called when only a single feature is provided', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={0}
totalFeatures={1}
/>
);
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.simulate('click');
expect(nextFeature).toHaveBeenCalledTimes(0);
});
});
describe('Within bounds, multiple features', () => {
test('previousButton is enabled when featureIndex > 0 && featureIndex < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={1}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.prop('disabled')
).toBe(false);
});
test('previousFunction is called when featureIndex > 0 && featureIndex < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={1}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="previous-feature-button"]')
.first()
.simulate('click');
expect(previousFeature).toHaveBeenCalledTimes(1);
});
test('nextButton is enabled when featureIndex > 0 && featureIndex < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={1}
totalFeatures={5}
/>
);
expect(
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.prop('disabled')
).toBe(false);
});
test('nextFunction is called when featureIndex > 0 && featureIndex < totalFeatures', () => {
const wrapper = mount(
<ToolTipFooter
nextFeature={nextFeature}
previousFeature={previousFeature}
featureIndex={1}
totalFeatures={5}
/>
);
wrapper
.find('[data-test-subj="next-feature-button"]')
.first()
.simulate('click');
expect(nextFeature).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiText,
} from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import styled from 'styled-components';
import * as i18n from '../translations';
export const Icon = styled(EuiIcon)`
margin-right: ${theme.euiSizeS};
`;
Icon.displayName = 'Icon';
interface MapToolTipFooterProps {
featureIndex: number;
totalFeatures: number;
previousFeature: () => void;
nextFeature: () => void;
}
export const ToolTipFooter = React.memo<MapToolTipFooterProps>(
({ featureIndex, totalFeatures, previousFeature, nextFeature }) => {
return (
<>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiText size="xs">
{i18n.MAP_TOOL_TIP_FEATURES_FOOTER(featureIndex + 1, totalFeatures)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>
<EuiButtonIcon
data-test-subj={'previous-feature-button'}
color={'text'}
onClick={previousFeature}
iconType="arrowLeft"
aria-label="Next"
disabled={featureIndex <= 0}
/>
<EuiButtonIcon
data-test-subj={'next-feature-button'}
color={'text'}
onClick={nextFeature}
iconType="arrowRight"
aria-label="Next"
disabled={featureIndex >= totalFeatures - 1}
/>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
);
ToolTipFooter.displayName = 'ToolTipFooter';

View file

@ -13,6 +13,27 @@ export const MAP_TITLE = i18n.translate(
}
);
export const SOURCE_LAYER = i18n.translate(
'xpack.siem.components.embeddables.embeddedMap.sourceLayerLabel',
{
defaultMessage: 'Source Point',
}
);
export const DESTINATION_LAYER = i18n.translate(
'xpack.siem.components.embeddables.embeddedMap.destinationLayerLabel',
{
defaultMessage: 'Destination Point',
}
);
export const LINE_LAYER = i18n.translate(
'xpack.siem.components.embeddables.embeddedMap.lineLayerLabel',
{
defaultMessage: 'Line',
}
);
export const ERROR_CONFIGURING_EMBEDDABLES_API = i18n.translate(
'xpack.siem.components.embeddables.embeddedMap.errorConfiguringEmbeddableApiTitle',
{
@ -48,3 +69,87 @@ export const ERROR_BUTTON = i18n.translate(
defaultMessage: 'Configure index patterns',
}
);
export const FILTER_FOR_VALUE = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.filterForValueHoverAction',
{
defaultMessage: 'Filter for value',
}
);
export const MAP_TOOL_TIP_ERROR = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.errorTitle',
{
defaultMessage: 'Error loading map features',
}
);
export const MAP_TOOL_TIP_FEATURES_FOOTER = (currentFeature: number, totalFeatures: number) =>
i18n.translate('xpack.siem.components.embeddables.mapToolTip.footerLabel', {
values: { currentFeature, totalFeatures },
defaultMessage:
'{currentFeature} of {totalFeatures} {totalFeatures, plural, =1 {feature} other {features}}',
});
export const HOST = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.hostTitle',
{
defaultMessage: 'Host',
}
);
export const SOURCE_IP = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.sourceIPTitle',
{
defaultMessage: 'Source IP',
}
);
export const DESTINATION_IP = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.destinationIPTitle',
{
defaultMessage: 'Destination IP',
}
);
export const SOURCE_DOMAIN = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.sourceDomainTitle',
{
defaultMessage: 'Source domain',
}
);
export const DESTINATION_DOMAIN = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.destinationDomainTitle',
{
defaultMessage: 'Destination domain',
}
);
export const LOCATION = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.locationTitle',
{
defaultMessage: 'Location',
}
);
export const ASN = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.pointContent.asnTitle',
{
defaultMessage: 'ASN',
}
);
export const SOURCE = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.lineContent.sourceLabel',
{
defaultMessage: 'Source',
}
);
export const DESTINATION = i18n.translate(
'xpack.siem.components.embeddables.mapToolTip.lineContent.destinationLabel',
{
defaultMessage: 'Destination',
}
);

View file

@ -39,3 +39,36 @@ export type SetQuery = (params: {
loading: boolean;
refetch: inputsModel.Refetch;
}) => void;
export interface MapFeature {
id: number;
layerId: string;
}
export interface LoadFeatureProps {
layerId: string;
featureId: number;
}
export interface FeatureProperty {
_propertyKey: string;
_rawValue: string;
getESFilters(): Promise<object>;
}
export interface FeatureGeometry {
coordinates: [number];
type: string;
}
export interface RenderTooltipContentParams {
addFilters(filter: object): void;
closeTooltip(): void;
features: MapFeature[];
isLocked: boolean;
getLayerName(layerId: string): Promise<string>;
loadFeatureProperties({ layerId, featureId }: LoadFeatureProps): Promise<FeatureProperty[]>;
loadFeatureGeometry({ layerId, featureId }: LoadFeatureProps): FeatureGeometry;
}
export type MapToolTipProps = Partial<RenderTooltipContentParams>;

View file

@ -50,7 +50,7 @@ const AddToKqlComponent = React.memo<Props>(
AddToKqlComponent.displayName = 'AddToKqlComponent';
const HoverActionsContainer = styled(EuiPanel)`
export const HoverActionsContainer = styled(EuiPanel)`
align-items: center;
display: flex;
flex-direction: row;

View file

@ -4,12 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDescriptionList, EuiFlexItem } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import { getOr } from 'lodash/fp';
import React, { useContext, useState } from 'react';
import styled from 'styled-components';
import { DEFAULT_DARK_MODE } from '../../../../../common/constants';
import { DescriptionList } from '../../../../../common/utility_types';
@ -24,7 +23,7 @@ import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_p
import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
import { Anomalies, NarrowDateRange } from '../../../ml/types';
import { OverviewWrapper } from '../../index';
import { DescriptionListStyled, OverviewWrapper } from '../../index';
import { FirstLastSeenHost, FirstLastSeenHostType } from '../first_last_seen_host';
import * as i18n from './translations';
@ -40,16 +39,6 @@ interface HostSummaryProps {
narrowDateRange: NarrowDateRange;
}
const DescriptionListStyled = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
}
`}
`;
DescriptionListStyled.displayName = 'DescriptionListStyled';
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => (
<EuiFlexItem key={key}>
<DescriptionListStyled listItems={descriptionList} />

View file

@ -5,7 +5,14 @@
*/
import React from 'react';
import { EuiBadge, EuiBadgeProps, EuiFlexGroup, EuiIcon, EuiPage } from '@elastic/eui';
import {
EuiBadge,
EuiBadgeProps,
EuiDescriptionList,
EuiFlexGroup,
EuiIcon,
EuiPage,
} from '@elastic/eui';
import styled, { injectGlobal } from 'styled-components';
// SIDE EFFECT: the following `injectGlobal` overrides default styling in angular code that was not theme-friendly
@ -20,6 +27,16 @@ injectGlobal`
}
`;
export const DescriptionListStyled = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
}
`}
`;
DescriptionListStyled.displayName = 'DescriptionListStyled';
export const PageContainer = styled.div`
display: flex;
flex-direction: column;

View file

@ -4,12 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDescriptionList, EuiFlexItem } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import React, { useContext, useState } from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DEFAULT_DARK_MODE } from '../../../../../common/constants';
import { DescriptionList } from '../../../../../common/utility_types';
@ -28,7 +27,7 @@ import {
whoisRenderer,
} from '../../../field_renderers/field_renderers';
import * as i18n from './translations';
import { OverviewWrapper } from '../../index';
import { DescriptionListStyled, OverviewWrapper } from '../../index';
import { Loader } from '../../../loader';
import { Anomalies, NarrowDateRange } from '../../../ml/types';
import { AnomalyScores } from '../../../ml/score/anomaly_scores';
@ -52,16 +51,6 @@ interface OwnProps {
export type IpOverviewProps = OwnProps;
const DescriptionListStyled = styled(EuiDescriptionList)`
${({ theme }) => `
dt {
font-size: ${theme.eui.euiFontSizeXS} !important;
}
`}
`;
DescriptionListStyled.displayName = 'DescriptionListStyled';
const getDescriptionList = (descriptionList: DescriptionList[], key: number) => {
return (
<EuiFlexItem key={key}>

View file

@ -327,6 +327,7 @@
"react-redux": "^5.1.1",
"react-redux-request": "^1.5.6",
"react-resize-detector": "^4.2.0",
"react-reverse-portal": "^1.0.2",
"react-router-dom": "^4.3.1",
"react-select": "^1.2.1",
"react-shortcuts": "^2.0.0",

View file

@ -23311,6 +23311,11 @@ react-resize-detector@^4.0.5, react-resize-detector@^4.2.0:
raf-schd "^4.0.0"
resize-observer-polyfill "^1.5.1"
react-reverse-portal@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/react-reverse-portal/-/react-reverse-portal-1.0.3.tgz#38cd2d40f40862352dd63905f9b923f9c41f474b"
integrity sha512-mCtpp3BzPedmGTAMqT2v5U1hwnAvRfSqMusriON/GxnedT9gvNNTvai24NnrfKfQ78zqPo4e3N5nWPLpY7bORQ==
react-router-dom@4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.2.2.tgz#c8a81df3adc58bba8a76782e946cbd4eae649b8d"