diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index d5c0242535010..0664608d73c27 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; - +import copy from 'copy-to-clipboard'; import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher @@ -14,6 +14,10 @@ import { urlSearch } from '../test_utilities/url_search'; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + describe(`Resolver: when analyzing a tree with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => { /** * Get (or lazily create and get) the simulator. @@ -112,6 +116,16 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and wordBreaks: 2, }); }); + it('should allow all node details to be copied', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); }); const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, { @@ -158,6 +172,19 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and ).toYieldEqualTo(3); }); + it('should be able to copy the timestamps for all 3 nodes', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + expect(copyableFields?.length).toBe(3); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); + describe('when there is an item in the node list and its text has been clicked', () => { beforeEach(async () => { const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title'); @@ -239,6 +266,34 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and ) ).toYieldEqualTo(2); }); + describe('and when the first event link is clicked', () => { + beforeEach(async () => { + const link = await simulator().resolve( + 'resolver:panel:node-events-in-category:event-link' + ); + const first = link?.first(); + expect(first).toBeTruthy(); + + if (first) { + first.simulate('click', { button: 0 }); + } + }); + it('should show the event detail view', async () => { + await expect( + simulator().map(() => simulator().testSubject('resolver:panel:event-detail').length) + ).toYieldEqualTo(1); + }); + it('should allow all fields to be copied', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); + }); }); }); describe('and when the node list link has been clicked', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx new file mode 100644 index 0000000000000..356dd1c73678e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx @@ -0,0 +1,80 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { EuiToolTip, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import React, { memo, useState, useContext } from 'react'; +import { WithCopyToClipboard } from '../../../common/lib/clipboard/with_copy_to_clipboard'; +import { useColors } from '../use_colors'; +import { ResolverPanelContext } from './panel_context'; + +interface StyledCopyableField { + readonly backgroundColor: string; + readonly activeBackgroundColor: string; +} + +const StyledCopyableField = styled.div` + background-color: ${(props) => props.backgroundColor}; + border-radius: 3px; + padding: 4px; + transition: background 0.2s ease; + + &:hover { + background-color: ${(props) => props.activeBackgroundColor}; + color: #fff; + } +`; + +export const CopyablePanelField = memo( + ({ textToCopy, content }: { textToCopy: string; content: JSX.Element | string }) => { + const { linkColor, copyableBackground } = useColors(); + const [isOpen, setIsOpen] = useState(false); + const panelContext = useContext(ResolverPanelContext); + + const onMouseEnter = () => setIsOpen(true); + + const ButtonContent = memo(() => ( + + {content} + + )); + + const onMouseLeave = () => setIsOpen(false); + + return ( +
+ } + closePopover={onMouseLeave} + hasArrow={false} + isOpen={isOpen} + panelPaddingSize="s" + > + + + + +
+ ); + } +); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 109112254300e..e5569b30abb9d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -21,6 +21,7 @@ import { GeneratedText, noTimestampRetrievedText, } from './panel_content_utilities'; +import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; @@ -156,7 +157,12 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) { namespace: {key}, descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({ title: {path.join('.')}, - description: {String(fieldValue)}, + description: ( + {String(fieldValue)}} + /> + ), })), }; returnValue.push(section); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index 396050420f54e..043229dbeda66 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -6,7 +6,7 @@ /* eslint-disable react/display-name */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { useSelector } from 'react-redux'; import * as selectors from '../../store/selectors'; import { NodeEventsInCategory } from './node_events_of_type'; @@ -15,33 +15,48 @@ import { NodeDetail } from './node_detail'; import { NodeList } from './node_list'; import { EventDetail } from './event_detail'; import { PanelViewAndParameters } from '../../types'; +import { ResolverPanelContext } from './panel_context'; /** * Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search) */ + export const PanelRouter = memo(function () { const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters); + const [isHoveringInPanel, updateIsHoveringInPanel] = useState(false); + + const triggerPanelHover = () => updateIsHoveringInPanel(true); + const stopPanelHover = () => updateIsHoveringInPanel(false); + + /* The default 'Event List' / 'List of all processes' view */ + let panelViewToRender = ; + if (params.panelView === 'nodeDetail') { - return ; + panelViewToRender = ; } else if (params.panelView === 'nodeEvents') { - return ; + panelViewToRender = ; } else if (params.panelView === 'nodeEventsInCategory') { - return ( + panelViewToRender = ( ); } else if (params.panelView === 'eventDetail') { - return ( + panelViewToRender = ( ); - } else { - /* The default 'Event List' / 'List of all processes' view */ - return ; } + + return ( + +
+ {panelViewToRender} +
+
+ ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 1c62cff8ed62e..c7d4f8632659b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -17,6 +17,7 @@ import { StyledDescriptionList, StyledTitle } from './styles'; import * as selectors from '../../store/selectors'; import * as eventModel from '../../../../common/endpoint/models/event'; import { GeneratedText } from './panel_content_utilities'; +import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; import { CubeForProcess } from './cube_for_process'; @@ -131,7 +132,12 @@ const NodeDetailView = memo(function ({ .map((entry) => { return { ...entry, - description: {String(entry.description)}, + description: ( + {String(entry.description)}} + /> + ), }; }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 78d3477301539..06e3acfb3dc6d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -42,6 +42,7 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { ResolverAction } from '../../store/actions'; import { useFormattedDate } from './use_formatted_date'; import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { CopyablePanelField } from './copyable_panel_field'; interface ProcessTableView { name?: string; @@ -214,5 +215,9 @@ function NodeDetailLink({ const NodeDetailTimestamp = memo(({ eventDate }: { eventDate: Date | undefined }) => { const formattedDate = useFormattedDate(eventDate); - return formattedDate ? <>{formattedDate} : getEmptyTagValue(); + return formattedDate ? ( + + ) : ( + getEmptyTagValue() + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx new file mode 100644 index 0000000000000..60109c732c46d --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx @@ -0,0 +1,9 @@ +/* + * 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'; + +export const ResolverPanelContext = React.createContext({ isHoveringInPanel: false }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts index 8072266f1e8c8..6f0cbcb3fd876 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts @@ -10,10 +10,12 @@ import { useMemo } from 'react'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; type ResolverColorNames = + | 'copyableBackground' | 'descriptionText' | 'full' | 'graphControls' | 'graphControlsBackground' + | 'linkColor' | 'resolverBackground' | 'resolverEdge' | 'resolverEdgeText' @@ -31,6 +33,7 @@ export function useColors(): ColorMap { const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; return useMemo(() => { return { + copyableBackground: theme.euiColorLightShade, descriptionText: theme.euiTextColor, full: theme.euiColorFullShade, graphControls: theme.euiColorDarkestShade, @@ -42,6 +45,7 @@ export function useColors(): ColorMap { resolverEdgeText: isDarkMode ? theme.euiColorFullShade : theme.euiColorDarkShade, triggerBackingFill: `${theme.euiColorDanger}${isDarkMode ? '1F' : '0F'}`, pillStroke: theme.euiColorLightShade, + linkColor: theme.euiLinkColor, }; }, [isDarkMode, theme]); }