diff --git a/x-pack/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts b/x-pack/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts index 076c685aca5b9..126e1d6a3fc6f 100644 --- a/x-pack/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts +++ b/x-pack/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts @@ -89,6 +89,9 @@ export const labelNodeDataSchema = schema.allOf([ shape: schema.literal('label'), parentId: schema.maybe(schema.string()), color: colorSchema, + badge: schema.number(), + lastEventId: schema.string(), + lastEventIdx: schema.string(), }), ]); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx index 898e12b5b4c01..02ec2e9acb134 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { BaseEdge, getBezierPath } from '@xyflow/react'; +import { BaseEdge, getSmoothStepPath } from '@xyflow/react'; import { useEuiTheme } from '@elastic/eui'; import type { Color } from '@kbn/cloud-security-posture-common/types/graph/latest'; import type { EdgeProps } from '../types'; @@ -27,7 +27,7 @@ export function DefaultEdge({ const { euiTheme } = useEuiTheme(); const color: Color = data?.color ?? 'primary'; - const [edgePath] = getBezierPath({ + const [edgePath] = getSmoothStepPath({ // sourceX and targetX are adjusted to account for the shape handle position sourceX: sourceX - getShapeHandlePosition(data?.sourceShape), sourceY, @@ -35,6 +35,8 @@ export function DefaultEdge({ targetX: targetX + getShapeHandlePosition(data?.targetShape), targetY, targetPosition, + borderRadius: 15, + offset: 0, curvature: 0.1 * (data?.sourceShape === 'group' || diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx index 0b956cb19e10d..a97a1c74698ca 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx @@ -174,7 +174,7 @@ export const Graph: React.FC = ({ minZoom={0.1} > {interactive && } - {' '} + ); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx index 6d5b3c1b372fc..3dc77e015c98d 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from '@emotion/react'; import { Story } from '@storybook/react'; import { css } from '@emotion/react'; import { EuiListGroup, EuiHorizontalRule } from '@elastic/eui'; -import type { EntityNodeViewModel, NodeProps } from '..'; +import type { EntityNodeViewModel, LabelNodeViewModel, NodeProps } from '..'; import { Graph } from '..'; import { GraphPopover } from './graph_popover'; import { ExpandButtonClickCallback } from '../types'; @@ -173,18 +173,30 @@ const Template: Story = () => { popoverOpenWrapper(expandNodePopover.onNodeExpandButtonClick, ...args); const nodeClickHandler = (...args: any[]) => popoverOpenWrapper(nodePopover.onNodeClick, ...args); - const nodes: EntityNodeViewModel[] = useMemo( - () => - (['hexagon', 'ellipse', 'rectangle', 'pentagon', 'diamond'] as const).map((shape, idx) => ({ - id: `${idx}`, - label: `Node ${idx}`, + const nodes: Array = useMemo( + () => [ + ...(['hexagon', 'ellipse', 'rectangle', 'pentagon', 'diamond'] as const).map( + (shape, idx) => ({ + id: `${idx}`, + label: `Node ${idx}`, + color: 'primary', + icon: 'okta', + interactive: true, + shape, + expandButtonClick: expandButtonClickHandler, + nodeClick: nodeClickHandler, + }) + ), + { + id: 'label', + label: 'Label Node', color: 'primary', - icon: 'okta', interactive: true, - shape, + shape: 'label', expandButtonClick: expandButtonClickHandler, nodeClick: nodeClickHandler, - })), + }, + ], // eslint-disable-next-line react-hooks/exhaustive-deps [] ); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx index 570c1332a8834..65d0b5a2b89b8 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx @@ -40,20 +40,8 @@ export const GraphPopover: React.FC = ({ {...rest} panelProps={{ css: css` - .euiPopover__arrow[data-popover-arrow='left']:before { - border-inline-start-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='right']:before { - border-inline-end-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='bottom']:before { - border-block-end-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='top']:before { - border-block-start-color: ${euiTheme.colors?.body}; + .euiPopover__arrow { + --euiPopoverBackgroundColor: ${euiTheme.colors?.body}; } background-color: ${euiTheme.colors?.body}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts index 868461f99cdee..d0cf9f0f150cd 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts @@ -50,6 +50,10 @@ export const layoutGraph = ( nodesById[node.id] = node; } + if (node.parentId) { + return; + } + g.setNode(node.id, { ...node, ...size, @@ -59,6 +63,14 @@ export const layoutGraph = ( Dagre.layout(g); const layoutedNodes = nodes.map((node) => { + // For grouped nodes, we want to keep the original position relative to the parent + if (node.data.shape === 'label' && node.data.parentId) { + return { + ...node, + position: nodesById[node.data.id].position, + }; + } + const dagreNode = g.node(node.data.id); // We are shifting the dagre node position (anchor=center center) to the top left @@ -66,13 +78,7 @@ export const layoutGraph = ( const x = dagreNode.x - (dagreNode.width ?? 0) / 2; const y = dagreNode.y - (dagreNode.height ?? 0) / 2; - // For grouped nodes, we want to keep the original position relative to the parent - if (node.data.shape === 'label' && node.data.parentId) { - return { - ...node, - position: nodesById[node.data.id].position, - }; - } else if (node.data.shape === 'group') { + if (node.data.shape === 'group') { return { ...node, position: { x, y }, @@ -130,7 +136,7 @@ const layoutGroupChildren = ( const childSize = calcLabelSize(child.data.label); child.position = { x: groupNodeWidth / 2 - childSize.width / 2, - y: index * (childSize.height * 2 + space), + y: index * (childSize.height + space), }; }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts index 2b050aa55429f..dccb31c7de04c 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { NodeViewModel } from './types'; + export { Graph } from './graph/graph'; export { GraphPopover } from './graph/graph_popover'; export { useGraphPopover } from './graph/use_graph_popover'; @@ -17,3 +19,10 @@ export type { EntityNodeViewModel, NodeProps, } from './types'; + +export const isEntityNode = (node: NodeViewModel) => + node.shape === 'ellipse' || + node.shape === 'pentagon' || + node.shape === 'rectangle' || + node.shape === 'diamond' || + node.shape === 'hexagon'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx index 75ad989b625e8..18e1ce829eaf1 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx @@ -28,6 +28,7 @@ export const DiamondNode: React.FC = memo((props: NodeProps) => { const { id, color, icon, label, interactive, expandButtonClick, nodeClick } = props.data as EntityNodeViewModel; const { euiTheme } = useEuiTheme(); + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; return ( <> @@ -50,7 +51,7 @@ export const DiamondNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -80,7 +81,7 @@ export const DiamondNode: React.FC = memo((props: NodeProps) => { style={HandleStyleOverride} /> - {Boolean(label) ? label : id} + ); }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx index c9bd363130dca..f86e4cee90df0 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx @@ -28,6 +28,7 @@ export const EllipseNode: React.FC = memo((props: NodeProps) => { const { id, color, icon, label, interactive, expandButtonClick, nodeClick } = props.data as EntityNodeViewModel; const { euiTheme } = useEuiTheme(); + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; return ( <> @@ -50,7 +51,7 @@ export const EllipseNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -80,7 +81,7 @@ export const EllipseNode: React.FC = memo((props: NodeProps) => { style={HandleStyleOverride} /> - {Boolean(label) ? label : id} + ); }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx index 9d155999e76ca..f9a5211f5862d 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx @@ -28,6 +28,7 @@ export const HexagonNode: React.FC = memo((props: NodeProps) => { const { id, color, icon, label, interactive, expandButtonClick, nodeClick } = props.data as EntityNodeViewModel; const { euiTheme } = useEuiTheme(); + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; return ( <> @@ -50,7 +51,7 @@ export const HexagonNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -80,7 +81,7 @@ export const HexagonNode: React.FC = memo((props: NodeProps) => { style={HandleStyleOverride} /> - {Boolean(label) ? label : id} + ); }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx index 2f23e5ab07513..b1506ed952495 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx @@ -7,18 +7,52 @@ import React, { memo } from 'react'; import { Handle, Position } from '@xyflow/react'; -import { LabelNodeContainer, LabelShape, HandleStyleOverride, LabelShapeOnHover } from './styles'; +import { css } from '@emotion/react'; +import { + LabelNodeContainer, + LabelShape, + HandleStyleOverride, + LabelShapeOnHover, + NodeButton, + LABEL_PADDING_X, + LABEL_BORDER_WIDTH, +} from './styles'; import type { LabelNodeViewModel, NodeProps } from '../types'; +import { NodeExpandButton } from './node_expand_button'; +import { getTextWidth } from '../graph/utils'; export const LabelNode: React.FC = memo((props: NodeProps) => { - const { id, color, label, interactive } = props.data as LabelNodeViewModel; + const { id, color, label, interactive, nodeClick, expandButtonClick } = + props.data as LabelNodeViewModel; + const text = Boolean(label) ? label : id; + const labelWidth = Math.max( + 100, + getTextWidth(text ?? '') + LABEL_PADDING_X * 2 + LABEL_BORDER_WIDTH * 2 + ); return ( {interactive && } - {Boolean(label) ? label : id} + {text} + {interactive && ( + <> + nodeClick?.(e, props)} + /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${labelWidth}px`} + y={`${-24 + (24 - NodeExpandButton.ExpandButtonSize) / 2}px`} + /> + + )} { onClick={onClickHandler} iconSize="m" aria-label="Open or close node actions" + data-test-subj="nodeExpandButton" /> ); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_label.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_label.tsx new file mode 100644 index 0000000000000..92c282f384b6d --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_label.tsx @@ -0,0 +1,85 @@ +/* + * 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, EuiTextTruncate, EuiToolTip } from '@elastic/eui'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +const NODE_WIDTH = 90; +const NODE_LABEL_WIDTH = 160; + +const GeneratedText = React.memo>(function ({ children }) { + return <>{processedValue()}; + + function processedValue() { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const valueSplitByWordBoundaries = child.split(/\b/); + + if (valueSplitByWordBoundaries.length < 2) { + return valueSplitByWordBoundaries[0]; + } + + return [ + valueSplitByWordBoundaries[0], + ...valueSplitByWordBoundaries + .splice(1) + .reduce(function (generatedTextMemo: Array, value) { + return [...generatedTextMemo, value, ]; + }, []), + ]; + } else { + return child; + } + }); + } +}); + +export interface NodeLabelProps { + text?: string; +} + +const NodeLabelComponent: React.FC = ({ text = '' }: NodeLabelProps) => { + const [isTruncated, setIsTruncated] = React.useState(false); + + return ( + + + + {(truncatedText) => ( + <> + {setIsTruncated(truncatedText.length !== text.length)} + {{truncatedText}} + + )} + + + + ); +}; + +export const NodeLabel = styled(NodeLabelComponent)` + width: ${NODE_LABEL_WIDTH}px; + margin-left: ${-(NODE_LABEL_WIDTH - NODE_WIDTH) / 2}px; + text-overflow: ellipsis; + // white-space: nowrap; + overflow: hidden; +`; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx index f2745cef7ec80..6a8b63c01e3f8 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx @@ -33,6 +33,7 @@ export const PentagonNode: React.FC = memo((props: NodeProps) => { const { id, color, icon, label, interactive, expandButtonClick, nodeClick } = props.data as EntityNodeViewModel; const { euiTheme } = useEuiTheme(); + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; return ( <> @@ -55,7 +56,7 @@ export const PentagonNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -85,7 +86,7 @@ export const PentagonNode: React.FC = memo((props: NodeProps) => { style={HandleStyleOverride} /> - {Boolean(label) ? label : id} + ); }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx index f85b102c7e445..9b2a9dc9d7106 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx @@ -28,6 +28,7 @@ export const RectangleNode: React.FC = memo((props: NodeProps) => { const { id, color, icon, label, interactive, expandButtonClick, nodeClick } = props.data as EntityNodeViewModel; const { euiTheme } = useEuiTheme(); + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; return ( <> @@ -50,7 +51,7 @@ export const RectangleNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -80,7 +81,7 @@ export const RectangleNode: React.FC = memo((props: NodeProps) => { style={HandleStyleOverride} /> - {Boolean(label) ? label : id} + ); }); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx index 74b3f3ebe0156..418017709fef8 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx @@ -15,25 +15,31 @@ import { EuiText, useEuiBackgroundColor, useEuiTheme, + CommonProps, } from '@elastic/eui'; import { rgba } from 'polished'; import { getSpanIcon } from './get_span_icon'; import type { NodeExpandButtonProps } from './node_expand_button'; +export { NodeLabel } from './node_label'; + export const LABEL_PADDING_X = 15; export const LABEL_BORDER_WIDTH = 1; export const NODE_WIDTH = 90; export const NODE_HEIGHT = 90; -const NODE_LABEL_WIDTH = 120; export const LabelNodeContainer = styled.div` + position: relative; text-wrap: nowrap; min-width: 100px; height: 24px; `; export const LabelShape = styled(EuiText)` - background: ${(props) => useEuiBackgroundColor(props.color as _EuiBackgroundColor)}; + background: ${({ color }) => { + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; + return useEuiBackgroundColor(fillColor as _EuiBackgroundColor); + }}; border: ${(props) => { const { euiTheme } = useEuiTheme(); return `solid ${ @@ -101,26 +107,28 @@ export const NodeShapeSvg = styled.svg` z-index: 1; `; -export interface NodeButtonProps { +export interface NodeButtonProps extends CommonProps { + width?: number; + height?: number; onClick?: (e: React.MouseEvent) => void; } -export const NodeButton: React.FC = ({ onClick }) => ( - - +export const NodeButton: React.FC = ({ onClick, width, height, ...props }) => ( + + ); -const StyledNodeContainer = styled.div` +const StyledNodeContainer = styled.div` position: absolute; - width: ${NODE_WIDTH}px; - height: ${NODE_HEIGHT}px; + width: ${(props) => props.width ?? NODE_WIDTH}px; + height: ${(props) => props.height ?? NODE_HEIGHT}px; z-index: 1; `; -const StyledNodeButton = styled.div` - width: ${NODE_WIDTH}px; - height: ${NODE_HEIGHT}px; +const StyledNodeButton = styled.div` + width: ${(props) => props.width ?? NODE_WIDTH}px; + height: ${(props) => props.height ?? NODE_HEIGHT}px; `; export const StyledNodeExpandButton = styled.div` @@ -136,7 +144,7 @@ export const StyledNodeExpandButton = styled.div` opacity: 1; } - ${NodeShapeContainer}:hover & { + ${NodeShapeContainer}:hover &, ${LabelNodeContainer}:hover & { opacity: 1; /* Show on hover */ } @@ -153,11 +161,11 @@ export const NodeShapeOnHoverSvg = styled(NodeShapeSvg)` opacity: 0; /* Hidden by default */ transition: opacity 0.2s ease; /* Smooth transition */ - ${NodeShapeContainer}:hover & { + ${NodeShapeContainer}:hover &, ${LabelNodeContainer}:hover & { opacity: 1; /* Show on hover */ } - ${NodeShapeContainer}:has(${StyledNodeExpandButton}.toggled) & { + ${NodeShapeContainer}:has(${StyledNodeExpandButton}.toggled) &, ${LabelNodeContainer}:has(${StyledNodeExpandButton}.toggled) & { opacity: 1; /* Show on hover */ } @@ -185,19 +193,6 @@ export const NodeIcon = ({ icon, color, x, y }: NodeIconProps) => { ); }; -export const NodeLabel = styled(EuiText)` - width: ${NODE_LABEL_WIDTH}px; - margin-left: ${-(NODE_LABEL_WIDTH - NODE_WIDTH) / 2}px; - text-overflow: ellipsis; - // white-space: nowrap; - overflow: hidden; -`; - -NodeLabel.defaultProps = { - size: 'xs', - textAlign: 'center', -}; - export const ExpandButtonSize = 18; export const RoundEuiButtonIcon = styled(EuiButtonIcon)` diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx index 0efff1c88456c..be776d57be12a 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx @@ -6,13 +6,16 @@ */ import React from 'react'; +import type { + EuiIconProps, + _EuiBackgroundColor, + CommonProps, + EuiListGroupItemProps, +} from '@elastic/eui'; import { - EuiIcon, useEuiBackgroundColor, useEuiTheme, - type EuiIconProps, - type _EuiBackgroundColor, - EuiListGroupItemProps, + EuiIcon, EuiListGroupItem, EuiText, } from '@elastic/eui'; @@ -59,22 +62,24 @@ const RoundedEuiIcon: React.FC = ({ color, background, ...r ); export const ExpandPopoverListItem: React.FC< - Pick + CommonProps & Pick > = (props) => { + const { iconType, label, onClick, ...rest } = props; const { euiTheme } = useEuiTheme(); return ( + iconType ? ( + ) : undefined } label={ - {props.label} + {label} } - onClick={props.onClick} + onClick={onClick} /> ); }; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts index 328829ee3fabe..7f44937389848 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts @@ -50,6 +50,7 @@ export interface LabelNodeViewModel extends Record, LabelNodeDataModel, BaseNodeDataViewModel { + nodeClick?: NodeClickCallback; expandButtonClick?: ExpandButtonClickCallback; } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts index 9fb817b275a0d..fd42061472044 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts @@ -12,9 +12,12 @@ import { import { transformError } from '@kbn/securitysolution-es-utils'; import type { GraphRequest } from '@kbn/cloud-security-posture-common/types/graph/v1'; import { GRAPH_ROUTE_PATH } from '../../../common/constants'; -import { CspRouter } from '../../types'; +import { CspRequestHandlerContext, CspRouter } from '../../types'; import { getGraph as getGraphV1 } from './v1'; +const ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING = + 'securitySolution:enableVisualizationsInFlyout' as const; + export const defineGraphRoute = (router: CspRouter) => router.versioned .post({ @@ -39,10 +42,20 @@ export const defineGraphRoute = (router: CspRouter) => }, }, }, - async (context, request, response) => { + async (context: CspRequestHandlerContext, request, response) => { + const cspContext = await context.csp; + const isGraphEnabled = await ( + await context.core + ).uiSettings.client.get(ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING); + + cspContext.logger.debug(`isGraphEnabled: ${isGraphEnabled}`); + + if (!isGraphEnabled) { + return response.notFound(); + } + const { nodesLimit, showUnknownTarget = false } = request.body; const { eventIds, start, end, esQuery } = request.body.query as GraphRequest['query']; - const cspContext = await context.csp; const spaceId = (await cspContext.spaces?.spacesService?.getActiveSpace(request))?.id; try { diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts index b14a2ba3e06a9..7a6ab18ba4b1a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts @@ -32,6 +32,8 @@ interface GraphEdge { actorIds: string[] | string; action: string; targetIds: string[] | string; + lastEventId: string; + lastEventIdx: string; eventOutcome: string; isAlert: boolean; } @@ -144,23 +146,29 @@ const fetchGraph = async ({ showUnknownTarget: boolean; esQuery?: EsQuery; }): Promise> => { - const query = `from logs-* + const query = `from logs-* METADATA _id, _index | WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL | EVAL isAlert = ${ eventIds.length > 0 ? `event.id in (${eventIds.map((_id, idx) => `?al_id${idx}`).join(', ')})` : 'false' } +| EVAL eventId = CONCAT(TO_STRING(\`@timestamp\`), "~", _id) +| EVAL eventIdx = CONCAT(TO_STRING(\`@timestamp\`), "~", _index) | STATS badge = COUNT(*), ips = VALUES(related.ip), // hosts = VALUES(related.hosts), - users = VALUES(related.user) + users = VALUES(related.user), + lastEventId = MAX(eventId), + lastEventIdx = MAX(eventIdx) by actorIds = actor.entity.id, action = event.action, targetIds = target.entity.id, eventOutcome = event.outcome, isAlert | LIMIT 1000 +| EVAL lastEventId = MV_SLICE(SPLIT(lastEventId, "~"), 1) +| EVAL lastEventIdx = MV_SLICE(SPLIT(lastEventIdx, "~"), 1) | SORT isAlert DESC`; logger.trace(`Executing query [${query}]`); @@ -281,6 +289,9 @@ const createNodes = (records: GraphEdge[], context: Omit) => { diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index df812fe534722..d5dc022bfcf66 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -22,6 +22,7 @@ import type { Logger, SavedObjectsClientContract, IScopedClusterClient, + CoreRequestHandlerContext, } from '@kbn/core/server'; import type { AgentService, @@ -88,6 +89,7 @@ export type CspRequestHandlerContext = CustomRequestHandlerContext<{ csp: CspApiRequestHandlerContext; fleet: FleetRequestHandlerContext['fleet']; alerting: AlertingApiRequestHandlerContext; + core: Promise; }>; /** diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 7fcdabad3b36c..1a97e42a7cff9 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -185,11 +185,6 @@ export const allowedExperimentalValues = Object.freeze({ */ analyzerDatePickersAndSourcererDisabled: false, - /** - * Enables graph visualization in alerts flyout - */ - graphVisualizationInFlyoutEnabled: false, - /** * Enables an ability to customize Elastic prebuilt rules. * diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_label_expand_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_label_expand_popover.tsx new file mode 100644 index 0000000000000..489ee838431da --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_label_expand_popover.tsx @@ -0,0 +1,69 @@ +/* + * 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, { memo } from 'react'; +import { GraphPopover } from '@kbn/cloud-security-posture-graph'; +import { EuiHorizontalRule, EuiListGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ExpandPopoverListItem } from '@kbn/cloud-security-posture-graph/src/components/styles'; +import { + GRAPH_LABEL_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID, + GRAPH_LABEL_POPOVER_VIEW_EVENT_DETAILS_ITEM_ID, +} from './test_ids'; + +interface GraphLabelExpandPopoverProps { + isOpen: boolean; + anchorElement: HTMLElement | null; + closePopover: () => void; + onShowEventsWithThisActionClick: () => void; + onViewEventDetailsClick: () => void; +} + +export const GraphLabelExpandPopover: React.FC = memo( + ({ + isOpen, + anchorElement, + closePopover, + onShowEventsWithThisActionClick, + onViewEventDetailsClick, + }) => { + return ( + + + + + + + + ); + } +); + +GraphLabelExpandPopover.displayName = 'GraphLabelExpandPopover'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_node_expand_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_node_expand_popover.tsx new file mode 100644 index 0000000000000..d8ae56bb7fe3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_node_expand_popover.tsx @@ -0,0 +1,91 @@ +/* + * 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, { memo } from 'react'; +import { GraphPopover } from '@kbn/cloud-security-posture-graph'; +import { EuiListGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ExpandPopoverListItem } from '@kbn/cloud-security-posture-graph/src/components/styles'; +import { + GRAPH_NODE_POPOVER_EXPLORE_RELATED_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID, +} from './test_ids'; + +interface GraphNodeExpandPopoverProps { + isOpen: boolean; + anchorElement: HTMLElement | null; + closePopover: () => void; + onExploreRelatedEntitiesClick: () => void; + onShowActionsByEntityClick: () => void; + onShowActionsOnEntityClick: () => void; + // onViewEntityDetailsClick: () => void; +} + +export const GraphNodeExpandPopover: React.FC = memo( + ({ + isOpen, + anchorElement, + closePopover, + onExploreRelatedEntitiesClick, + onShowActionsByEntityClick, + onShowActionsOnEntityClick, + // onViewEntityDetailsClick, + }) => { + return ( + + + + + + {/* + */} + + + ); + } +); + +GraphNodeExpandPopover.displayName = 'GraphNodeExpandPopover'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx new file mode 100644 index 0000000000000..ccf647b8b8df2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx @@ -0,0 +1,367 @@ +/* + * 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, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { isEntityNode } from '@kbn/cloud-security-posture-graph'; +import type { NodeViewModel } from '@kbn/cloud-security-posture-graph'; +import { + BooleanRelation, + buildEsQuery, + isCombinedFilter, + buildCombinedFilter, + isPhraseFilter, +} from '@kbn/es-query'; +import type { Filter, Query, TimeRange, BoolQuery, PhraseFilter } from '@kbn/es-query'; +import { css } from '@emotion/css'; +import { getEsQueryConfig } from '@kbn/data-service'; +import dateMath from '@kbn/datemath'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { normalizeTimeRange } from '../../../../common/utils/normalize_time_range'; +import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button'; +import { useGetScopedSourcererDataView } from '../../../../sourcerer/components/use_get_sourcerer_data_view'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; +import { useFetchGraphData } from '../../shared/hooks/use_fetch_graph_data'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { useGraphNodeExpandPopover } from './use_graph_node_expand_popover'; +import { useGraphLabelExpandPopover } from './use_graph_label_expand_popover'; +import { ALERT_PREVIEW_BANNER, EVENT_PREVIEW_BANNER } from '../../preview/constants'; +import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; + +export const GRAPH_VISUALIZATION_ID = 'graph_visualization'; +const CONTROLLED_BY_GRAPH_VISUALIZATION_FILTER = 'graph-visualization'; + +const GraphLazy = React.lazy(() => + import('@kbn/cloud-security-posture-graph').then((module) => ({ default: module.Graph })) +); + +const useTimeRange = (timestamp: string) => { + const [timeRange, setTimeRange] = useState({ + from: `${timestamp}||-30m`, + to: `${timestamp}||+30m`, + }); + + const setPartialTimeRange = (newTimeRange: Partial) => { + setTimeRange((currTimeRange) => ({ ...currTimeRange, ...newTimeRange })); + }; + + return { timeRange, setTimeRange, setPartialTimeRange }; +}; + +const useGraphData = ( + eventIds: string[], + isAlert: boolean, + timeRange: TimeRange, + filter: { bool: BoolQuery } +) => { + const { data, refresh, isFetching } = useFetchGraphData({ + req: { + query: { + eventIds: isAlert ? eventIds : [], + esQuery: filter, + start: timeRange.from, + end: timeRange.to, + }, + nodesLimit: 50, + showUnknownTarget: false, + }, + options: { + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + }); + + return { data, refresh, isFetching }; +}; + +const buildPhraseFilter = (field: string, value: string, dataViewId?: string): PhraseFilter => ({ + meta: { + key: field, + index: dataViewId, + negate: false, + disabled: false, + type: 'phrase', + field, + controlledBy: CONTROLLED_BY_GRAPH_VISUALIZATION_FILTER, + params: { + query: value, + }, + }, + query: { + match_phrase: { + [field]: value, + }, + }, +}); + +const addFilter = (dataViewId: string, prev: Filter[], key: string, value: string) => { + const [firstFilter, ...otherFilters] = prev; + + if (isCombinedFilter(firstFilter) && firstFilter?.meta?.relation === BooleanRelation.OR) { + return [ + { + ...firstFilter, + meta: { + ...firstFilter.meta, + params: [ + ...(Array.isArray(firstFilter.meta.params) ? firstFilter.meta.params : []), + buildPhraseFilter(key, value), + ], + }, + }, + ...otherFilters, + ]; + } else if (isPhraseFilter(firstFilter)) { + return [ + buildCombinedFilter(BooleanRelation.OR, [firstFilter, buildPhraseFilter(key, value)], { + id: dataViewId, + }), + ...otherFilters, + ]; + } else { + return [buildPhraseFilter(key, value, dataViewId), ...prev]; + } +}; + +type NodeEventOnClick = ({ + documentId, + indexName, + scopeId, + isAlert, +}: { + documentId: string | undefined; + indexName: string | undefined; + scopeId: string; + isAlert: boolean; +}) => void; + +const useGraphPopovers = ( + dataViewId: string, + setSearchFilters: React.Dispatch> +) => { + const { openPreviewPanel } = useExpandableFlyoutApi(); + + const openPreview = useCallback( + ({ documentId, indexName, scopeId, isAlert }) => { + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + params: { + id: documentId, + indexName, + scopeId, + isPreviewMode: true, + banner: isAlert ? ALERT_PREVIEW_BANNER : EVENT_PREVIEW_BANNER, + }, + }); + }, + [openPreviewPanel] + ); + + const nodeExpandPopover = useGraphNodeExpandPopover({ + onExploreRelatedEntitiesClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, 'related.entity', node.id)); + }, + onShowActionsByEntityClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, 'actor.entity.id', node.id)); + }, + onShowActionsOnEntityClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, 'target.entity.id', node.id)); + }, + // onViewEntityDetailsClick: (node) => { + // + // }, + }); + + const labelExpandPopover = useGraphLabelExpandPopover({ + onShowEventsWithThisActionClick: (node) => { + setSearchFilters((prev) => + addFilter(dataViewId, prev, 'event.action', node.data.label ?? '') + ); + }, + onViewEventDetailsClick: (node) => { + openPreview({ + documentId: node.data.lastEventId, + indexName: node.data.lastEventIdx, + scopeId: TableId.alertsOnAlertsPage, + isAlert: node.data.color === 'primary', + }); + }, + }); + + const popovers = [nodeExpandPopover, labelExpandPopover]; + const popoverOpenWrapper = (cb: Function, ...args: unknown[]) => { + popovers.forEach(({ actions: { closePopover } }) => { + closePopover(); + }); + cb(...args); + }; + + return { nodeExpandPopover, labelExpandPopover, popoverOpenWrapper }; +}; + +const useGraphNodes = ( + nodes: NodeViewModel[], + nodeExpandButtonClickHandler: (...args: unknown[]) => void, + labelExpandButtonClickHandler: (...args: unknown[]) => void +) => { + return useMemo(() => { + return nodes.map((node) => { + if (isEntityNode(node)) { + return { + ...node, + expandButtonClick: nodeExpandButtonClickHandler, + }; + } else if (node.shape === 'label') { + return { + ...node, + expandButtonClick: labelExpandButtonClickHandler, + }; + } + + return { ...node }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodes]); +}; + +/** + * Graph visualization view displayed in the document details expandable flyout left section under the Visualize tab + */ +export const GraphVisualization: React.FC = memo(() => { + const dataView = useGetScopedSourcererDataView({ + sourcererScope: SourcererScopeName.default, + }); + const [searchFilters, setSearchFilters] = useState(() => []); + const { getFieldsData, dataAsNestedObject } = useDocumentDetailsContext(); + const { eventIds, isAlert, timestamp } = useGraphPreview({ + getFieldsData, + ecsData: dataAsNestedObject, + }); + + const { timeRange, setTimeRange } = useTimeRange(timestamp ?? new Date().toISOString()); + + const { + services: { uiSettings }, + } = useKibana(); + const [query, setQuery] = useState<{ bool: BoolQuery }>( + buildEsQuery( + dataView, + [], + isAlert + ? [...searchFilters] + : addFilter(dataView?.id ?? '', searchFilters, 'event.id', eventIds[0]), + getEsQueryConfig(uiSettings as Parameters[0]) + ) + ); + + useEffect(() => { + setQuery( + buildEsQuery( + dataView, + [], + isAlert + ? [...searchFilters] + : addFilter(dataView?.id ?? '', searchFilters, 'event.id', eventIds[0]), + getEsQueryConfig(uiSettings as Parameters[0]) + ) + ); + }, [searchFilters, dataView, uiSettings, isAlert, eventIds]); + + const { nodeExpandPopover, labelExpandPopover, popoverOpenWrapper } = useGraphPopovers( + dataView?.id ?? '', + setSearchFilters + ); + const nodeExpandButtonClickHandler = (...args: unknown[]) => + popoverOpenWrapper(nodeExpandPopover.onNodeExpandButtonClick, ...args); + const labelExpandButtonClickHandler = (...args: unknown[]) => + popoverOpenWrapper(labelExpandPopover.onLabelExpandButtonClick, ...args); + const isPopoverOpen = [nodeExpandPopover].some(({ state: { isOpen } }) => isOpen); + const { data, refresh, isFetching } = useGraphData(eventIds, isAlert, timeRange, query); + const nodes = useGraphNodes( + data?.nodes ?? [], + nodeExpandButtonClickHandler, + labelExpandButtonClickHandler + ); + const parsedTimeRange: TimeRange = useMemo(() => { + return { + ...timeRange, + from: dateMath.parse(timeRange.from), + to: dateMath.parse(timeRange.to), + }; + }, [timeRange]); + + return ( +
+ {dataView && ( + + {...{ + appName: 'graph-visualization', + intl: null, + showFilterBar: true, + showDatePicker: true, + showAutoRefreshOnly: false, + showSaveQuery: false, + showQueryInput: false, + isLoading: isFetching, + isAutoRefreshDisabled: true, + dateRangeFrom: timeRange.from, + dateRangeTo: timeRange.to, + query: { query: '', language: 'kuery' }, + indexPatterns: [dataView], + filters: searchFilters, + submitButtonStyle: 'iconOnly', + onFiltersUpdated: (newFilters) => { + setSearchFilters(newFilters); + }, + onQuerySubmit: (payload, isUpdate) => { + if (isUpdate) { + setTimeRange({ ...payload.dateRange }); + } else { + refresh(); + } + }, + }} + /> + )} + + + + + + + + +
+ ); +}); + +GraphVisualization.displayName = 'GraphVisualization'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 8669b504f6861..1d24db3582d21 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -11,6 +11,18 @@ import { PREFIX } from '../../../shared/test_ids'; export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const; export const SESSION_VIEW_TEST_ID = `${PREFIX}SessionView` as const; +export const GRAPH_VISUALIZATION_TEST_ID = `${PREFIX}GraphVisualization` as const; +export const GRAPH_NODE_POPOVER_EXPLORE_RELATED_ITEM_ID = + `${GRAPH_VISUALIZATION_TEST_ID}ExploreRelatedEntities` as const; +export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID = + `${GRAPH_VISUALIZATION_TEST_ID}ShowActionsByEntity` as const; +export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID = + `${GRAPH_VISUALIZATION_TEST_ID}ShowActionsOnEntity` as const; + +export const GRAPH_LABEL_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID = + `${GRAPH_VISUALIZATION_TEST_ID}ShowEventsWithThisAction` as const; +export const GRAPH_LABEL_POPOVER_VIEW_EVENT_DETAILS_ITEM_ID = + `${GRAPH_VISUALIZATION_TEST_ID}ViewEventDetails` as const; /* Insights tab */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_label_expand_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_label_expand_popover.tsx new file mode 100644 index 0000000000000..be0a5b71d597a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_label_expand_popover.tsx @@ -0,0 +1,109 @@ +/* + * 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 { useGraphPopover } from '@kbn/cloud-security-posture-graph'; +import type { + ExpandButtonClickCallback, + NodeProps, +} from '@kbn/cloud-security-posture-graph/src/components/types'; +import type { PopoverActions } from '@kbn/cloud-security-posture-graph/src/components/graph/use_graph_popover'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { GraphLabelExpandPopover } from './graph_label_expand_popover'; + +interface UseGraphLabelExpandPopoverArgs { + onShowEventsWithThisActionClick: (node: NodeProps) => void; + onViewEventDetailsClick: (node: NodeProps) => void; +} + +export const useGraphLabelExpandPopover = ({ + onShowEventsWithThisActionClick, + onViewEventDetailsClick, +}: UseGraphLabelExpandPopoverArgs) => { + const { id, state, actions } = useGraphPopover('label-expand-popover'); + const { openPopover, closePopover } = actions; + + const selectedNode = useRef(null); + const unToggleCallbackRef = useRef<(() => void) | null>(null); + const [pendingOpen, setPendingOpen] = useState<{ + node: NodeProps; + el: HTMLElement; + unToggleCallback: () => void; + } | null>(null); + + const closePopoverHandler = useCallback(() => { + selectedNode.current = null; + unToggleCallbackRef.current?.(); + unToggleCallbackRef.current = null; + closePopover(); + }, [closePopover]); + + const onLabelExpandButtonClick: ExpandButtonClickCallback = useCallback( + (e, node, unToggleCallback) => { + if (selectedNode.current?.id === node.id) { + // If the same node is clicked again, close the popover + closePopoverHandler(); + } else { + // Close the current popover if open + closePopoverHandler(); + + // Set the pending open state + setPendingOpen({ node, el: e.currentTarget, unToggleCallback }); + } + }, + [closePopoverHandler] + ); + + useEffect(() => { + // Open pending popover if the popover is not open + if (!state.isOpen && pendingOpen) { + const { node, el, unToggleCallback } = pendingOpen; + + selectedNode.current = node; + unToggleCallbackRef.current = unToggleCallback; + openPopover(el); + + setPendingOpen(null); + } + }, [state.isOpen, pendingOpen, openPopover]); + + const PopoverComponent = memo(() => ( + { + onShowEventsWithThisActionClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + onViewEventDetailsClick={() => { + onViewEventDetailsClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + /> + )); + + PopoverComponent.displayName = GraphLabelExpandPopover.displayName; + + const actionsWithClose: PopoverActions = useMemo( + () => ({ + ...actions, + closePopover: closePopoverHandler, + }), + [actions, closePopoverHandler] + ); + + return useMemo( + () => ({ + onLabelExpandButtonClick, + PopoverComponent, + id, + actions: actionsWithClose, + state, + }), + [PopoverComponent, actionsWithClose, id, onLabelExpandButtonClick, state] + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_node_expand_popover.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_node_expand_popover.tsx new file mode 100644 index 0000000000000..3db96d37362dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/use_graph_node_expand_popover.tsx @@ -0,0 +1,121 @@ +/* + * 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 { useGraphPopover } from '@kbn/cloud-security-posture-graph'; +import type { + ExpandButtonClickCallback, + NodeProps, +} from '@kbn/cloud-security-posture-graph/src/components/types'; +import type { PopoverActions } from '@kbn/cloud-security-posture-graph/src/components/graph/use_graph_popover'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { GraphNodeExpandPopover } from './graph_node_expand_popover'; + +interface UseGraphNodeExpandPopoverArgs { + onExploreRelatedEntitiesClick: (node: NodeProps) => void; + onShowActionsByEntityClick: (node: NodeProps) => void; + onShowActionsOnEntityClick: (node: NodeProps) => void; + // onViewEntityDetailsClick: (node: NodeProps) => void; +} + +export const useGraphNodeExpandPopover = ({ + onExploreRelatedEntitiesClick, + onShowActionsByEntityClick, + onShowActionsOnEntityClick, +}: // onViewEntityDetailsClick, +UseGraphNodeExpandPopoverArgs) => { + const { id, state, actions } = useGraphPopover('node-expand-popover'); + const { openPopover, closePopover } = actions; + + const selectedNode = useRef(null); + const unToggleCallbackRef = useRef<(() => void) | null>(null); + const [pendingOpen, setPendingOpen] = useState<{ + node: NodeProps; + el: HTMLElement; + unToggleCallback: () => void; + } | null>(null); + + const closePopoverHandler = useCallback(() => { + selectedNode.current = null; + unToggleCallbackRef.current?.(); + unToggleCallbackRef.current = null; + closePopover(); + }, [closePopover]); + + const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback( + (e, node, unToggleCallback) => { + if (selectedNode.current?.id === node.id) { + // If the same node is clicked again, close the popover + closePopoverHandler(); + } else { + // Close the current popover if open + closePopoverHandler(); + + // Set the pending open state + setPendingOpen({ node, el: e.currentTarget, unToggleCallback }); + } + }, + [closePopoverHandler] + ); + + useEffect(() => { + // Open pending popover if the popover is not open + if (!state.isOpen && pendingOpen) { + const { node, el, unToggleCallback } = pendingOpen; + + selectedNode.current = node; + unToggleCallbackRef.current = unToggleCallback; + openPopover(el); + + setPendingOpen(null); + } + }, [state.isOpen, pendingOpen, openPopover]); + + const PopoverComponent = memo(() => ( + { + onExploreRelatedEntitiesClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + onShowActionsByEntityClick={() => { + onShowActionsByEntityClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + onShowActionsOnEntityClick={() => { + onShowActionsOnEntityClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + // onViewEntityDetailsClick={() => { + // onViewEntityDetailsClick(selectedNode.current as NodeProps); + // closePopoverHandler(); + // }} + /> + )); + + PopoverComponent.displayName = GraphNodeExpandPopover.displayName; + + const actionsWithClose: PopoverActions = useMemo( + () => ({ + ...actions, + closePopover: closePopoverHandler, + }), + [actions, closePopoverHandler] + ); + + return useMemo( + () => ({ + onNodeExpandButtonClick, + PopoverComponent, + id, + actions: actionsWithClose, + state, + }), + [PopoverComponent, actionsWithClose, id, onNodeExpandButtonClick, state] + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts index eb64c91b2143d..bc1ee586606de 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts @@ -13,6 +13,8 @@ export const VISUALIZE_TAB_SESSION_VIEW_BUTTON_TEST_ID = `${VISUALIZE_TAB_TEST_ID}SessionViewButton` as const; export const VISUALIZE_TAB_GRAPH_ANALYZER_BUTTON_TEST_ID = `${VISUALIZE_TAB_TEST_ID}GraphAnalyzerButton` as const; +export const VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID = + `${VISUALIZE_TAB_TEST_ID}GraphVisualizationButton` as const; const INSIGHTS_TAB_TEST_ID = `${PREFIX}InsightsTab` as const; export const INSIGHTS_TAB_BUTTON_GROUP_TEST_ID = `${INSIGHTS_TAB_TEST_ID}ButtonGroup` as const; export const INSIGHTS_TAB_ENTITIES_BUTTON_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx index 0dad444ee6ece..9a7024583d6c4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx @@ -17,6 +17,7 @@ import { DocumentDetailsAnalyzerPanelKey } from '../../shared/constants/panel_ke import { VISUALIZE_TAB_BUTTON_GROUP_TEST_ID, VISUALIZE_TAB_GRAPH_ANALYZER_BUTTON_TEST_ID, + VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID, VISUALIZE_TAB_SESSION_VIEW_BUTTON_TEST_ID, } from './test_ids'; import { @@ -27,6 +28,8 @@ import { import { SESSION_VIEW_ID, SessionView } from '../components/session_view'; import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; +import { GRAPH_VISUALIZATION_ID, GraphVisualization } from '../components/graph_visualization'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; const visualizeButtons: EuiButtonGroupOptionProps[] = [ { @@ -51,11 +54,39 @@ const visualizeButtons: EuiButtonGroupOptionProps[] = [ }, ]; +const graphVisualizationButton: EuiButtonGroupOptionProps = { + id: GRAPH_VISUALIZATION_ID, + iconType: 'beaker', + iconSide: 'right', + toolTipProps: { + title: ( + + ), + }, + toolTipContent: i18n.translate( + 'xpack.securitySolution.flyout.left.visualize.graphVisualizationButton.technicalPreviewTooltip', + { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + } + ), + label: ( + + ), + 'data-test-subj': VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID, +}; + /** * Visualize view displayed in the document details expandable flyout left section */ export const VisualizeTab = memo(() => { - const { scopeId } = useDocumentDetailsContext(); + const { scopeId, getFieldsData, dataAsNestedObject } = useDocumentDetailsContext(); const { openPreviewPanel } = useExpandableFlyoutApi(); const panels = useExpandableFlyoutState(); const [activeVisualizationId, setActiveVisualizationId] = useState( @@ -86,6 +117,18 @@ export const VisualizeTab = memo(() => { } }, [panels.left?.path?.subTab]); + // Decide whether to show the graph preview or not + const { isAuditLog: isGraphPreviewEnabled } = useGraphPreview({ + getFieldsData, + ecsData: dataAsNestedObject, + }); + + const options = [...visualizeButtons]; + + if (isGraphPreviewEnabled) { + options.push(graphVisualizationButton); + } + return ( <> { defaultMessage: 'Visualize options', } )} - options={visualizeButtons} + options={options} idSelected={activeVisualizationId} onChange={(id) => onChangeCompressed(id)} buttonSize="compressed" @@ -107,6 +150,7 @@ export const VisualizeTab = memo(() => { {activeVisualizationId === SESSION_VIEW_ID && } {activeVisualizationId === ANALYZE_GRAPH_ID && } + {activeVisualizationId === GRAPH_VISUALIZATION_ID && } ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx index c3c6d65c7e986..efe0a861781b8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { memo, useMemo } from 'react'; -import { EuiSkeletonText } from '@elastic/eui'; +import { EuiPanel, EuiSkeletonText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -77,7 +77,13 @@ export const GraphPreview: React.FC = memo( defaultMessage="An error is preventing this alert from being visualized." /> ) : ( - }> + + + + } + > ({ +jest.mock('../../shared/hooks/use_graph_preview'); +jest.mock('../../shared/hooks/use_fetch_graph_data', () => ({ useFetchGraphData: jest.fn(), })); const mockUseFetchGraphData = useFetchGraphData as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx index 0b881b8f8d439..e23ef3cfb5da1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx @@ -7,23 +7,41 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useDocumentDetailsContext } from '../../shared/context'; import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; import { GraphPreview } from './graph_preview'; -import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; -import { useGraphPreview } from '../hooks/use_graph_preview'; +import { useFetchGraphData } from '../../shared/hooks/use_fetch_graph_data'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { useNavigateToGraphVisualization } from '../../shared/hooks/use_navigate_to_graph_visualization'; import { ExpandablePanel } from '../../../shared/components/expandable_panel'; /** * Graph preview under Overview, Visualizations. It shows a graph representation of entities. */ export const GraphPreviewContainer: React.FC = () => { - const { dataAsNestedObject, getFieldsData } = useDocumentDetailsContext(); + const { dataAsNestedObject, getFieldsData, eventId, indexName, scopeId, isPreviewMode } = + useDocumentDetailsContext(); + + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); + + const { navigateToGraphVisualization } = useNavigateToGraphVisualization({ + eventId, + indexName, + isFlyoutOpen: true, + scopeId, + }); const { eventIds, timestamp = new Date().toISOString(), isAuditLog, + isAlert, } = useGraphPreview({ getFieldsData, ecsData: dataAsNestedObject, @@ -33,9 +51,10 @@ export const GraphPreviewContainer: React.FC = () => { const { isLoading, isError, data } = useFetchGraphData({ req: { query: { - eventIds, + eventIds: isAlert ? eventIds : [], start: `${timestamp}||-30m`, end: `${timestamp}||+30m`, + esQuery: !isAlert ? { bool: { filter: [{ terms: { 'event.id': eventIds } }] } } : undefined, }, }, options: { @@ -54,7 +73,39 @@ export const GraphPreviewContainer: React.FC = () => { defaultMessage="Graph preview" /> ), - iconType: 'indexMapping', + headerContent: ( + + ), + iconType: visualizationInFlyoutEnabled ? 'arrowStart' : 'indexMapping', + ...(visualizationInFlyoutEnabled && + !isPreviewMode && { + link: { + callback: navigateToGraphVisualization, + tooltip: ( + + ), + }, + }), }} data-test-subj={GRAPH_PREVIEW_TEST_ID} content={ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx index 3aeb7d30f8e48..fef1d72c0df19 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx @@ -25,9 +25,8 @@ import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { useExpandSection } from '../hooks/use_expand_section'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useGraphPreview } from '../hooks/use_graph_preview'; -import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { useFetchGraphData } from '../../shared/hooks/use_fetch_graph_data'; jest.mock('../hooks/use_expand_section'); jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ @@ -53,11 +52,6 @@ jest.mock( jest.mock( '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver' ); -jest.mock('../../../../common/hooks/use_experimental_features', () => ({ - useIsExperimentalFeatureEnabled: jest.fn(), -})); - -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; const mockUseUiSetting = jest.fn().mockReturnValue([false]); jest.mock('@kbn/kibana-react-plugin/public', () => { @@ -67,11 +61,11 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { useUiSetting$: () => mockUseUiSetting(), }; }); -jest.mock('../hooks/use_graph_preview'); +jest.mock('../../shared/hooks/use_graph_preview'); const mockUseGraphPreview = useGraphPreview as jest.Mock; -jest.mock('../hooks/use_fetch_graph_data', () => ({ +jest.mock('../../shared/hooks/use_fetch_graph_data', () => ({ useFetchGraphData: jest.fn(), })); @@ -95,6 +89,7 @@ const renderVisualizationsSection = (contextValue = panelContextValue) => describe('', () => { beforeEach(() => { + mockUseUiSetting.mockReturnValue([false]); mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] }); mockUseAlertPrevalenceFromProcessTree.mockReturnValue({ loading: false, @@ -136,7 +131,7 @@ describe('', () => { }); (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); (useExpandSection as jest.Mock).mockReturnValue(true); - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + mockUseUiSetting.mockReturnValue([false]); const { getByTestId, queryByTestId } = renderVisualizationsSection(); expect(getByTestId(VISUALIZATIONS_SECTION_CONTENT_TEST_ID)).toBeVisible(); @@ -148,7 +143,7 @@ describe('', () => { it('should render the graph preview component if the feature is enabled', () => { (useExpandSection as jest.Mock).mockReturnValue(true); - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + mockUseUiSetting.mockReturnValue([true]); const { getByTestId } = renderVisualizationsSection(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx index c328036eece43..12ed787ae9c80 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx @@ -8,15 +8,16 @@ import React, { memo } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { useExpandSection } from '../hooks/use_expand_section'; import { AnalyzerPreviewContainer } from './analyzer_preview_container'; import { SessionPreviewContainer } from './session_preview_container'; import { ExpandableSection } from './expandable_section'; import { VISUALIZATIONS_TEST_ID } from './test_ids'; import { GraphPreviewContainer } from './graph_preview_container'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useDocumentDetailsContext } from '../../shared/context'; -import { useGraphPreview } from '../hooks/use_graph_preview'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; const KEY = 'visualizations'; @@ -25,12 +26,12 @@ const KEY = 'visualizations'; */ export const VisualizationsSection = memo(() => { const expanded = useExpandSection({ title: KEY, defaultValue: false }); - const graphVisualizationInFlyoutEnabled = useIsExperimentalFeatureEnabled( - 'graphVisualizationInFlyoutEnabled' - ); - const { dataAsNestedObject, getFieldsData } = useDocumentDetailsContext(); + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); + // Decide whether to show the graph preview or not const { isAuditLog: isGraphPreviewEnabled } = useGraphPreview({ getFieldsData, @@ -52,7 +53,7 @@ export const VisualizationsSection = memo(() => { - {graphVisualizationInFlyoutEnabled && isGraphPreviewEnabled && ( + {visualizationInFlyoutEnabled && isGraphPreviewEnabled && ( <> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_graph_data.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_graph_data.test.tsx diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_graph_data.ts similarity index 72% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_graph_data.ts index 9a0e270a9b2e0..964f5824f9074 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_graph_data.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { GraphRequest, GraphResponse, @@ -36,6 +36,11 @@ export interface UseFetchGraphDataParams { * Defaults to true. */ refetchOnWindowFocus?: boolean; + /** + * If true, the query will keep previous data till new data received. + * Defaults to false. + */ + keepPreviousData?: boolean; }; } @@ -44,9 +49,13 @@ export interface UseFetchGraphDataParams { */ export interface UseFetchGraphDataResult { /** - * Indicates if the query is currently loading. + * Indicates if the query is currently being fetched for the first time. */ isLoading: boolean; + /** + * Indicates if the query is currently being fetched. Regardless of whether it is the initial fetch or a refetch. + */ + isFetching: boolean; /** * Indicates if there was an error during the query. */ @@ -55,6 +64,10 @@ export interface UseFetchGraphDataResult { * The data returned from the query. */ data?: GraphResponse; + /** + * Function to manually refresh the query. + */ + refresh: () => void; } /** @@ -67,14 +80,15 @@ export const useFetchGraphData = ({ req, options, }: UseFetchGraphDataParams): UseFetchGraphDataResult => { - const { eventIds, start, end, esQuery } = req.query; + const queryClient = useQueryClient(); + const { esQuery, eventIds, start, end } = req.query; const http = useHttp(); const QUERY_KEY = useMemo( () => ['useFetchGraphData', eventIds, start, end, esQuery], [end, esQuery, eventIds, start] ); - const { isLoading, isError, data } = useQuery( + const { isLoading, isError, data, isFetching } = useQuery( QUERY_KEY, () => { return http.post(EVENT_GRAPH_VISUALIZATION_API, { @@ -85,12 +99,17 @@ export const useFetchGraphData = ({ { enabled: options?.enabled ?? true, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, + keepPreviousData: options?.keepPreviousData ?? false, } ); return { isLoading, + isFetching, isError, data, + refresh: () => { + queryClient.invalidateQueries(QUERY_KEY); + }, }; }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx similarity index 66% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx index d12154a390abf..679b631acf709 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx @@ -9,18 +9,30 @@ import type { RenderHookResult } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks'; import type { UseGraphPreviewParams, UseGraphPreviewResult } from './use_graph_preview'; import { useGraphPreview } from './use_graph_preview'; -import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; -import { mockFieldData } from '../../shared/mocks/mock_get_fields_data'; +import type { GetFieldsData } from './use_get_fields_data'; +import { mockFieldData } from '../mocks/mock_get_fields_data'; + +const mockGetFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'actor.entity.id') { + return 'actorId'; + } else if (field === 'target.entity.id') { + return 'targetId'; + } + + return mockFieldData[field]; +}; describe('useGraphPreview', () => { let hookResult: RenderHookResult; it(`should return false when missing actor`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; + if (field === 'actor.entity.id') { + return; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -35,22 +47,42 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual([]); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); it(`should return false when missing event.action`, () => { + hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: mockGetFieldsData, + ecsData: { + _id: 'id', + }, + }, + }); + + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(isAuditLog).toEqual(false); + expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); + expect(eventIds).toEqual(['eventId']); + expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); + expect(action).toEqual(undefined); + }); + + it(`should return false when missing target`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; + if (field === 'target.entity.id') { + return; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -62,20 +94,23 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual([]); expect(action).toEqual(undefined); }); it(`should return false when missing original_event.id`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'actor.entity.id') { - return 'actorId'; + if (field === 'kibana.alert.original_event.id') { + return; } - return mockFieldData[field]; + + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -90,11 +125,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual([]); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); @@ -102,13 +139,9 @@ describe('useGraphPreview', () => { const getFieldsData: GetFieldsData = (field: string) => { if (field === '@timestamp') { return; - } else if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -123,28 +156,20 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(false); expect(timestamp).toEqual(null); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); it(`should return true when alert is has graph preview`, () => { - const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; - } - - return mockFieldData[field]; - }; - hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { initialProps: { - getFieldsData, + getFieldsData: mockGetFieldsData, ecsData: { _id: 'id', event: { @@ -154,11 +179,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(true); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); @@ -168,6 +195,8 @@ describe('useGraphPreview', () => { return ['id1', 'id2']; } else if (field === 'actor.entity.id') { return ['actorId1', 'actorId2']; + } else if (field === 'target.entity.id') { + return ['targetId1', 'targetId2']; } return mockFieldData[field]; @@ -185,11 +214,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; + const { isAuditLog, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; expect(isAuditLog).toEqual(true); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['id1', 'id2']); expect(actorIds).toEqual(['actorId1', 'actorId2']); expect(action).toEqual(['action1', 'action2']); + expect(targetIds).toEqual(['targetId1', 'targetId2']); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts similarity index 74% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts index bbaeb808c9e2a..8f564d2c2438b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts @@ -6,9 +6,10 @@ */ import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; import { get } from 'lodash/fp'; -import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; -import { getField, getFieldArray } from '../../shared/utils'; +import type { GetFieldsData } from './use_get_fields_data'; +import { getField, getFieldArray } from '../utils'; export interface UseGraphPreviewParams { /** @@ -40,6 +41,11 @@ export interface UseGraphPreviewResult { */ actorIds: string[]; + /** + * Array of target entity IDs associated with the alert + */ + targetIds: string[]; + /** * Action associated with the event */ @@ -49,6 +55,8 @@ export interface UseGraphPreviewResult { * Boolean indicating if the event is an audit log (contains event ids, actor ids and action) */ isAuditLog: boolean; + + isAlert: boolean; } /** @@ -64,9 +72,15 @@ export const useGraphPreview = ({ const eventIds = originalEventId ? getFieldArray(originalEventId) : getFieldArray(eventId); const actorIds = getFieldArray(getFieldsData('actor.entity.id')); + const targetIds = getFieldArray(getFieldsData('target.entity.id')); const action: string[] | undefined = get(['event', 'action'], ecsData); const isAuditLog = - Boolean(timestamp) && actorIds.length > 0 && Boolean(action?.length) && eventIds.length > 0; + Boolean(timestamp) && + Boolean(action?.length) && + actorIds.length > 0 && + eventIds.length > 0 && + targetIds.length > 0; + const isAlert = Boolean(getFieldsData(ALERT_UUID)); - return { timestamp, eventIds, actorIds, action, isAuditLog }; + return { timestamp, eventIds, actorIds, action, targetIds, isAuditLog, isAlert }; }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx new file mode 100644 index 0000000000000..c4c362250a1ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { mockFlyoutApi } from '../mocks/mock_flyout_context'; +import { useWhichFlyout } from './use_which_flyout'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DocumentDetailsRightPanelKey, DocumentDetailsLeftPanelKey } from '../constants/panel_keys'; +import { GRAPH_VISUALIZATION_ID } from '../../left/components/graph_visualization'; +import { useNavigateToGraphVisualization } from './use_navigate_to_graph_visualization'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_which_flyout'); + +const mockedUseKibana = mockUseKibana(); +(useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + +const mockUseWhichFlyout = useWhichFlyout as jest.Mock; +const FLYOUT_KEY = 'SecuritySolution'; + +const eventId = 'eventId1'; +const indexName = 'index1'; +const scopeId = 'scopeId1'; + +describe('useNavigateToGraphVisualization', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + }); + + it('when isFlyoutOpen is true, should return callback that opens left and preview panels', () => { + mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); + const hookResult = renderHook(() => + useNavigateToGraphVisualization({ isFlyoutOpen: true, eventId, indexName, scopeId }) + ); + + // Act + hookResult.result.current.navigateToGraphVisualization(); + + expect(mockFlyoutApi.openLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: GRAPH_VISUALIZATION_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); + + it('when isFlyoutOpen is false and scopeId is not timeline, should return callback that opens a new flyout', () => { + mockUseWhichFlyout.mockReturnValue(null); + + const hookResult = renderHook(() => + useNavigateToGraphVisualization({ isFlyoutOpen: false, eventId, indexName, scopeId }) + ); + + // Act + hookResult.result.current.navigateToGraphVisualization(); + + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: GRAPH_VISUALIZATION_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx new file mode 100644 index 0000000000000..e363741866a97 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx @@ -0,0 +1,105 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { Maybe } from '@kbn/timelines-plugin/common/search_strategy/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey } from '../constants/panel_keys'; +import { GRAPH_VISUALIZATION_ID } from '../../left/components/graph_visualization'; +import { DocumentEventTypes } from '../../../../common/lib/telemetry'; + +export interface UseNavigateToGraphVisualizationParams { + /** + * When flyout is already open, call open left panel only + * When flyout is not open, open a new flyout + */ + isFlyoutOpen: boolean; + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: Maybe | undefined; + /** + * Scope id of the page + */ + scopeId: string; +} + +export interface UseNavigateToGraphVisualizationResult { + /** + * Callback to open analyzer in visualize tab + */ + navigateToGraphVisualization: () => void; +} + +/** + * Hook that returns the a callback to navigate to the graph visualization in the flyout + */ +export const useNavigateToGraphVisualization = ({ + isFlyoutOpen, + eventId, + indexName, + scopeId, +}: UseNavigateToGraphVisualizationParams): UseNavigateToGraphVisualizationResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); + + const right: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId] + ); + + const left: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsLeftPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + path: { + tab: 'visualize', + subTab: GRAPH_VISUALIZATION_ID, + }, + }), + [eventId, indexName, scopeId] + ); + + const navigateToGraphVisualization = useCallback(() => { + if (isFlyoutOpen) { + openLeftPanel(left); + telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutTabClicked, { + location: scopeId, + panel: 'left', + tabId: 'visualize', + }); + } else { + openFlyout({ + right, + left, + }); + telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, { + location: scopeId, + panel: 'left', + }); + } + }, [openFlyout, openLeftPanel, right, left, scopeId, telemetry, isFlyoutOpen]); + + return useMemo(() => ({ navigateToGraphVisualization }), [navigateToGraphVisualization]); +}; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 6aeb86750be7a..592c648792090 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -209,7 +209,7 @@ export const initUiSettings = ( 'xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutDescription', { defaultMessage: - '[technical preview] Enable visualizations (analyzer and session viewer) in flyout.', + '[technical preview] Enable visualizations (analyzer, session viewer, and graph view) in flyout.', values: { em: (chunks) => `${chunks}` }, } ), diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 018646b09b6b6..41a07f263a6b7 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -42842,7 +42842,6 @@ "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "

Active les avertissements de vérification des privilèges dans les règles relatives aux index CCS

", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "

Active le fil d'actualités

", "xpack.securitySolution.uiSettings.enableNewsFeedLabel": "Fil d'actualités", - "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutDescription": "[version d'évaluation technique] Activez les visualisations (analyseur et visualiseur de session) dans le menu volant.", "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutLabel": "Activer les visualisations dans le menu volant", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzer": "Exclure les niveaux froids de l'analyseur", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzerDescription": "

Lorsque cette option est activée, les niveaux \"cold\" et \"frozen\" sont ignorés dans les requêtes de l'analyseur

", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2f76f1a88b515..bc63ac1b5a621 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -42809,7 +42809,6 @@ "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "

CCSインデックスのルールで権限チェック警告を有効にします

", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "

ニュースフィードを有効にします

", "xpack.securitySolution.uiSettings.enableNewsFeedLabel": "ニュースフィード", - "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutDescription": "[テクニカルプレビュー]フライアウトで視覚化(アナライザーとセッションビューアー)を有効にします。", "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutLabel": "フライアウトでビジュアライゼーションを有効化", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzer": "アナライザーでコールドティアとフローズンティアを除外", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzerDescription": "

有効にすると、アナライザークエリーでコールドティアとフローズンティアがスキップされます

", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9dcbf8eacac51..b3b821da2de89 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -42158,7 +42158,6 @@ "xpack.securitySolution.uiSettings.enableCcsWarningDescription": "

在规则中为 CCS 索引启用权限检查警告

", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "

启用新闻源

", "xpack.securitySolution.uiSettings.enableNewsFeedLabel": "新闻源", - "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutDescription": "[技术预览] 在浮出控件中启用可视化(分析器和会话查看器)。", "xpack.securitySolution.uiSettings.enableVisualizationsInFlyoutLabel": "在浮出控件中启用可视化", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzer": "在分析器中排除冷层和冻结层", "xpack.securitySolution.uiSettings.excludeColdAndFrozenTiersInAnalyzerDescription": "

启用后,将在分析器查询中跳过冷层和冻结层

", diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/config.ts b/x-pack/test/api_integration/apis/cloud_security_posture/config.ts index 5f335f116fefe..81a2cd0df2fd5 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/config.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/config.ts @@ -5,13 +5,28 @@ * 2.0. */ -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts')); + const baseConfig = await readConfigFile(require.resolve('../../config.ts')); return { - ...baseIntegrationTestsConfig.getAll(), + ...baseConfig.getAll(), testFiles: [require.resolve('.')], + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + `--uiSettings.overrides.securitySolution:enableVisualizationsInFlyout=false`, // Disables /graph API + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(baseConfig.get('kbnTestServer.serverArgs')), + { + name: 'plugins.cloudSecurityPosture', + level: 'all', + appenders: ['default'], + }, + ])}`, + ], + }, }; } diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts b/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts new file mode 100644 index 0000000000000..bcf8ef10f483c --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts @@ -0,0 +1,50 @@ +/* + * 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 { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import type { Agent } from 'supertest'; +import type { GraphRequest } from '@kbn/cloud-security-posture-common/types/graph/latest'; +import { FtrProviderContext } from '@kbn/ftr-common-functional-services'; +import { result } from '../../../cloud_security_posture_api/utils'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + + const logger = getService('log'); + const supertest = getService('supertest'); + + const postGraph = (agent: Agent, body: GraphRequest, auth?: { user: string; pass: string }) => { + let req = agent + .post('/internal/cloud_security_posture/graph') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx'); + + if (auth) { + req = req.auth(auth.user, auth.pass); + } + + return req.send(body); + }; + + describe('POST /internal/cloud_security_posture/graph', () => { + describe('Feature flag', () => { + it('should return 404 when feature flag is not toggled', async () => { + await postGraph(supertest, { + query: { + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(404, logger)); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts index fa11aab67b279..46f594be3ed38 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts @@ -20,6 +20,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./benchmark/v2')); loadTestFile(require.resolve('./rules/v1')); loadTestFile(require.resolve('./rules/v2')); + loadTestFile(require.resolve('./graph')); // Place your tests files under this directory and add the following here: // loadTestFile(require.resolve('./your test name')); diff --git a/x-pack/test/cloud_security_posture_api/config.ts b/x-pack/test/cloud_security_posture_api/config.ts index 4e0ecd1f26e43..0dac59b47c52c 100644 --- a/x-pack/test/cloud_security_posture_api/config.ts +++ b/x-pack/test/cloud_security_posture_api/config.ts @@ -34,6 +34,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { * 1. release a new package to EPR * 2. merge the updated version number change to kibana */ + `--uiSettings.overrides.securitySolution:enableVisualizationsInFlyout=true`, // Enables /graph API `--xpack.fleet.packages.0.name=cloud_security_posture`, `--xpack.fleet.packages.0.version=${CLOUD_SECURITY_PLUGIN_VERSION}`, // `--xpack.fleet.registryUrl=https://localhost:8080`, diff --git a/x-pack/test/cloud_security_posture_api/routes/graph.ts b/x-pack/test/cloud_security_posture_api/routes/graph.ts index 95625b24fa59a..08adf73839ea2 100644 --- a/x-pack/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/test/cloud_security_posture_api/routes/graph.ts @@ -28,14 +28,14 @@ export default function (providerContext: FtrProviderContext) { const cspSecurity = CspSecurityCommonProvider(providerContext); const postGraph = (agent: Agent, body: GraphRequest, auth?: { user: string; pass: string }) => { - const req = agent + let req = agent .post('/internal/cloud_security_posture/graph') .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .set('kbn-xsrf', 'xxxx'); if (auth) { - req.auth(auth.user, auth.pass); + req = req.auth(auth.user, auth.pass); } return req.send(body); diff --git a/x-pack/test/cloud_security_posture_functional/config.ts b/x-pack/test/cloud_security_posture_functional/config.ts index 7e80788ffccfe..db560992e4fad 100644 --- a/x-pack/test/cloud_security_posture_functional/config.ts +++ b/x-pack/test/cloud_security_posture_functional/config.ts @@ -38,9 +38,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { * 1. release a new package to EPR * 2. merge the updated version number change to kibana */ - `--xpack.securitySolution.enableExperimental=${JSON.stringify([ - 'graphVisualizationInFlyoutEnabled', - ])}`, + `--uiSettings.overrides.securitySolution:enableVisualizationsInFlyout=true`, `--xpack.fleet.packages.0.name=cloud_security_posture`, `--xpack.fleet.packages.0.version=${CLOUD_SECURITY_PLUGIN_VERSION}`, // `--xpack.fleet.registryUrl=https://localhost:8080`, diff --git a/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json b/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json index 5e3d4cdfdffd5..e5b83d55c15ae 100644 --- a/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json +++ b/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json @@ -90,6 +90,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin@example.com" + ], "ip": [ "10.0.0.1" ], @@ -215,6 +220,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin2@example.com" + ], "ip": [ "10.0.0.1" ], @@ -340,6 +350,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin3@example.com" + ], "ip": [ "10.0.0.1" ], @@ -465,6 +480,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin3@example.com" + ], "ip": [ "10.0.0.1" ], @@ -599,6 +619,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin4@example.com" + ], "ip": [ "10.0.0.1" ], diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts index f3a9f7b1448a8..6ebd496fca365 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts @@ -10,7 +10,7 @@ import { FtrService } from '../../functional/ftr_provider_context'; const ALERT_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="alertsTable"] .euiDataGridRow'; const VISUALIZATIONS_SECTION_HEADER_TEST_ID = 'securitySolutionFlyoutVisualizationsHeader'; -const GRAPH_PREVIEW_TEST_ID = 'securitySolutionFlyoutGraphPreview'; +const GRAPH_PREVIEW_CONTENT_TEST_ID = 'securitySolutionFlyoutGraphPreviewContent'; const GRAPH_PREVIEW_LOADING_TEST_ID = 'securitySolutionFlyoutGraphPreviewLoading'; export class AlertsPageObject extends FtrService { @@ -89,12 +89,12 @@ export class AlertsPageObject extends FtrService { }, assertGraphPreviewVisible: async () => { - return await this.testSubjects.existOrFail(GRAPH_PREVIEW_TEST_ID); + return await this.testSubjects.existOrFail(GRAPH_PREVIEW_CONTENT_TEST_ID); }, assertGraphNodesNumber: async (expected: number) => { await this.flyout.waitGraphIsLoaded(); - const graph = await this.testSubjects.find(GRAPH_PREVIEW_TEST_ID); + const graph = await this.testSubjects.find(GRAPH_PREVIEW_CONTENT_TEST_ID); await graph.scrollIntoView(); const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); expect(nodes.length).to.be(expected); diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts b/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts new file mode 100644 index 0000000000000..062425f939477 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts @@ -0,0 +1,112 @@ +/* + * 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 expect from '@kbn/expect'; +import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; +import type { FilterBarService } from '@kbn/test-suites-src/functional/services/filter_bar'; +import { FtrService } from '../../functional/ftr_provider_context'; + +const GRAPH_PREVIEW_TITLE_LINK_TEST_ID = 'securitySolutionFlyoutGraphPreviewTitleLink'; +const GRAPH_VISUALIZATION_TEST_ID = 'securitySolutionFlyoutGraphVisualization'; +const NODE_EXPAND_BUTTON_TEST_ID = 'nodeExpandButton'; +const GRAPH_NODE_EXPAND_POPOVER_TEST_ID = 'graphNodeExpandPopover'; +const GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID = `${GRAPH_VISUALIZATION_TEST_ID}ExploreRelatedEntities`; +const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID = `${GRAPH_VISUALIZATION_TEST_ID}ShowActionsByEntity`; +const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID = `${GRAPH_VISUALIZATION_TEST_ID}ShowActionsOnEntity`; +type Filter = Parameters[0]; + +export class ExpandedFlyout extends FtrService { + private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly filterBar = this.ctx.getService('filterBar'); + + async expandGraph(): Promise { + await this.testSubjects.click(GRAPH_PREVIEW_TITLE_LINK_TEST_ID); + } + + async waitGraphIsLoaded(): Promise { + await this.testSubjects.existOrFail(GRAPH_VISUALIZATION_TEST_ID, { timeout: 10000 }); + } + + async assertGraphNodesNumber(expected: number): Promise { + await this.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_VISUALIZATION_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); + expect(nodes.length).to.be(expected); + } + + async selectNode(nodeId: string): Promise { + await this.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_VISUALIZATION_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector( + `.react-flow__nodes .react-flow__node[data-id="${nodeId}"]` + ); + expect(nodes.length).to.be(1); + await nodes[0].moveMouseTo(); + return nodes[0]; + } + + async clickOnNodeExpandButton(nodeId: string): Promise { + const node = await this.selectNode(nodeId); + const expandButton = await node.findByTestSubject(NODE_EXPAND_BUTTON_TEST_ID); + await expandButton.click(); + await this.testSubjects.existOrFail(GRAPH_NODE_EXPAND_POPOVER_TEST_ID); + } + + async showActionsByEntity(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async showActionsOnEntity(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async exploreRelatedEntities(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async expectFilterTextEquals(filterIdx: number, expected: string): Promise { + const filters = await this.filterBar.getFiltersLabel(); + expect(filters.length).to.be.greaterThan(filterIdx); + expect(filters[filterIdx]).to.be(expected); + } + + async expectFilterPreviewEquals(filterIdx: number, expected: string): Promise { + await this.clickEditFilter(filterIdx); + + const filterPreview = await this.filterBar.getFilterEditorPreview(); + expect(filterPreview).to.be(expected); + + await this.filterBar.ensureFieldEditorModalIsClosed(); + } + + async clickEditFilter(filterIdx: number): Promise { + await this.filterBar.clickEditFilterById(filterIdx.toString()); + } + + async clearAllFilters(): Promise { + await this.testSubjects.click(`${GRAPH_VISUALIZATION_TEST_ID} > showQueryBarMenu`); + await this.testSubjects.click('filter-sets-removeAllFilters'); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async addFilter(filter: Filter): Promise { + await this.testSubjects.click(`${GRAPH_VISUALIZATION_TEST_ID} > addFilter`); + await this.filterBar.createFilter(filter); + await this.testSubjects.scrollIntoView('saveFilter'); + await this.testSubjects.clickWhenNotDisabled('saveFilter'); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } +} diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/index.ts b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts index b7c20632e82f5..fdc904e31aac0 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/index.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts @@ -14,9 +14,13 @@ import { BenchmarkPagePageProvider } from './benchmark_page'; import { CspSecurityCommonProvider } from './security_common'; import { RulePagePageProvider } from './rule_page'; import { AlertsPageObject } from './alerts_page'; +import { NetworkEventsPageObject } from './network_events_page'; +import { ExpandedFlyout } from './expanded_flyout'; export const cloudSecurityPosturePageObjects = { alerts: AlertsPageObject, + networkEvents: NetworkEventsPageObject, + expandedFlyout: ExpandedFlyout, findings: FindingsPageProvider, cloudPostureDashboard: CspDashboardPageProvider, cisAddIntegration: AddCisIntegrationFormPageProvider, diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts new file mode 100644 index 0000000000000..8e03fae7eb7e0 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts @@ -0,0 +1,107 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrService } from '../../functional/ftr_provider_context'; + +const EVENTS_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="events-viewer-panel"] .euiDataGridRow'; +const VISUALIZATIONS_SECTION_HEADER_TEST_ID = 'securitySolutionFlyoutVisualizationsHeader'; +const GRAPH_PREVIEW_CONTENT_TEST_ID = 'securitySolutionFlyoutGraphPreviewContent'; +const GRAPH_PREVIEW_LOADING_TEST_ID = 'securitySolutionFlyoutGraphPreviewLoading'; + +export class NetworkEventsPageObject extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly defaultTimeoutMs = this.ctx.getService('config').get('timeouts.waitFor'); + + async navigateToNetworkEventsPage(urlQueryParams: string = ''): Promise { + await this.pageObjects.common.navigateToUrlWithBrowserHistory( + 'securitySolution', + '/network/events', + `${urlQueryParams && `?${urlQueryParams}`}`, + { + ensureCurrentUrl: false, + } + ); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + getAbsoluteTimerangeFilter(from: string, to: string) { + return `timerange=(global:(linkTo:!(),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; + } + + getFlyoutFilter(eventId: string) { + return `flyout=(preview:!(),right:(id:document-details-right,params:(id:%27${eventId}%27,indexName:logs-gcp.audit-default,scopeId:network-page-events)))`; + } + + /** + * Clicks the refresh button on the network events page and waits for it to complete + */ + async clickRefresh(): Promise { + await this.ensureOnNetworkEventsPage(); + await this.testSubjects.click('querySubmitButton'); + + // wait for refresh to complete + await this.retry.waitFor( + 'Network events pages refresh button to be enabled', + async (): Promise => { + const refreshButton = await this.testSubjects.find('querySubmitButton'); + + return (await refreshButton.isDisplayed()) && (await refreshButton.isEnabled()); + } + ); + } + + async ensureOnNetworkEventsPage(): Promise { + await this.testSubjects.existOrFail('network-details-headline'); + } + + async waitForListToHaveEvents(timeoutMs?: number): Promise { + const allEventRows = await this.testSubjects.findService.allByCssSelector( + EVENTS_TABLE_ROW_CSS_SELECTOR + ); + + if (!Boolean(allEventRows.length)) { + await this.retry.waitForWithTimeout( + 'waiting for events to show up on network events page', + timeoutMs ?? this.defaultTimeoutMs, + async (): Promise => { + await this.clickRefresh(); + + const allEventRowsInner = await this.testSubjects.findService.allByCssSelector( + EVENTS_TABLE_ROW_CSS_SELECTOR + ); + + return Boolean(allEventRowsInner.length); + } + ); + } + } + + flyout = { + expandVisualizations: async (): Promise => { + await this.testSubjects.click(VISUALIZATIONS_SECTION_HEADER_TEST_ID); + }, + + assertGraphPreviewVisible: async () => { + return await this.testSubjects.existOrFail(GRAPH_PREVIEW_CONTENT_TEST_ID); + }, + + assertGraphNodesNumber: async (expected: number) => { + await this.flyout.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_PREVIEW_CONTENT_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); + expect(nodes.length).to.be(expected); + }, + + waitGraphIsLoaded: async () => { + await this.testSubjects.missingOrFail(GRAPH_PREVIEW_LOADING_TEST_ID, { timeout: 10000 }); + }, + }; +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts index 63eafc4107bc1..35f9578929ada 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts @@ -14,8 +14,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const logger = getService('log'); const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const pageObjects = getPageObjects(['common', 'header', 'alerts']); + const pageObjects = getPageObjects(['common', 'header', 'alerts', 'expandedFlyout']); const alertsPage = pageObjects.alerts; + const expandedFlyout = pageObjects.expandedFlyout; describe('Security Alerts Page - Graph visualization', function () { this.tags(['cloud_security_posture_graph_viz']); @@ -54,9 +55,52 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); }); - it('should render graph visualization', async () => { + it('expanded flyout - filter by node', async () => { await alertsPage.flyout.assertGraphPreviewVisible(); await alertsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyout.expandGraph(); + await expandedFlyout.waitGraphIsLoaded(); + await expandedFlyout.assertGraphNodesNumber(3); + + // Show actions by entity + await expandedFlyout.showActionsByEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com'); + await expandedFlyout.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com'); + + // Show actions on entity + await expandedFlyout.showActionsOnEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + + // Explore related entities + await expandedFlyout.exploreRelatedEntities('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + + // Clear filters + await expandedFlyout.clearAllFilters(); + + // Add custom filter + await expandedFlyout.addFilter({ + field: 'actor.entity.id', + operation: 'is', + value: 'admin2@example.com', + }); + await pageObjects.header.waitUntilLoadingHasFinished(); + await expandedFlyout.assertGraphNodesNumber(5); }); }); } diff --git a/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts b/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts new file mode 100644 index 0000000000000..0848307ca26d2 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts @@ -0,0 +1,98 @@ +/* + * 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 { waitForPluginInitialized } from '../../cloud_security_posture_api/utils'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const retry = getService('retry'); + const logger = getService('log'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const pageObjects = getPageObjects(['common', 'header', 'networkEvents', 'expandedFlyout']); + const networkEventsPage = pageObjects.networkEvents; + const expandedFlyout = pageObjects.expandedFlyout; + + describe('Security Network Page - Graph visualization', function () { + this.tags(['cloud_security_posture_graph_viz']); + + before(async () => { + await esArchiver.load( + 'x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit' + ); + + await waitForPluginInitialized({ retry, supertest, logger }); + + // Setting the timerange to fit the data and open the flyout for a specific alert + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('1')}` + ); + + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit' + ); + }); + + it('expanded flyout - filter by node', async () => { + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyout.expandGraph(); + await expandedFlyout.waitGraphIsLoaded(); + await expandedFlyout.assertGraphNodesNumber(3); + + // Show actions by entity + await expandedFlyout.showActionsByEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com'); + await expandedFlyout.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com'); + + // Show actions on entity + await expandedFlyout.showActionsOnEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + + // Explore related entities + await expandedFlyout.exploreRelatedEntities('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + + // Clear filters + await expandedFlyout.clearAllFilters(); + + // Add custom filter + await expandedFlyout.addFilter({ + field: 'actor.entity.id', + operation: 'is', + value: 'admin2@example.com', + }); + await pageObjects.header.waitUntilLoadingHasFinished(); + await expandedFlyout.assertGraphNodesNumber(5); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index 0114b6a8ce4dc..67c06d979002f 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -37,5 +37,6 @@ export default function ({ getPageObjects, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./vulnerabilities_grouping')); loadTestFile(require.resolve('./benchmark')); loadTestFile(require.resolve('./alerts_flyout')); + loadTestFile(require.resolve('./events_flyout')); }); }