mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cloud Security] Show graph visualization in expanded flyout (#198240)
## Summary Added graph tab to the flyout visualization of alerts and events. **A couple of included changes:** - Added technical preview badge - ~Feature is now toggled using `securitySolution:enableVisualizationsInFlyout` advanced setting~ reverted back to use the experimental feature flag - Added node popover to expand the graph - Expanding a graph adds relevant filters - Added e2e tests for both alerts flyout and events flyout (through network page) **List of known issues:** - The graph API works queries `logs-*` while the filters bar works with sourcerer current dataview Id - I'm not sure how to write a UT for GraphVisualization / Popover which uses ReactPortal that makes it tricky to test (I covered most scenarios using E2E test) - Expanding graph more than once adds another filter **How to test this PR:** - Enable the feature flag `kibana.dev.yml`: ```yaml uiSettings.overrides.securitySolution:enableVisualizationsInFlyout: true xpack.securitySolution.enableExperimental: ['graphVisualizationInFlyoutEnabled'] ``` - Load mocked data: ```bash node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/security_alerts \ --es-url http://elastic:changeme@localhost:9200 \ --kibana-url http://elastic:changeme@localhost:5601 ``` - Make sure you include data from Oct 13 2024. (in the video I use Last 90 days) https://github.com/user-attachments/assets/12e19ac7-0f61-4c0a-ac11-e304dfcc83d4 ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b1363d925e
commit
749eeec4cc
48 changed files with 1850 additions and 156 deletions
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export * from './src/components';
|
||||
export { useFetchGraphData } from './src/hooks';
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const EVENT_GRAPH_VISUALIZATION_API = '/internal/cloud_security_posture/graph' as const;
|
||||
|
||||
export const RELATED_ENTITY = 'related.entity' as const;
|
||||
export const ACTOR_ENTITY_ID = 'actor.entity.id' as const;
|
||||
export const TARGET_ENTITY_ID = 'target.entity.id' as const;
|
|
@ -174,7 +174,7 @@ export const Graph: React.FC<GraphProps> = ({
|
|||
minZoom={0.1}
|
||||
>
|
||||
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
|
||||
<Background id={backgroundId} />{' '}
|
||||
<Background id={backgroundId} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -46,12 +46,9 @@ export const useGraphPopover = (id: string): GraphPopoverState => {
|
|||
|
||||
const state: PopoverState = useMemo(() => ({ isOpen, anchorElement }), [isOpen, anchorElement]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
id,
|
||||
actions,
|
||||
state,
|
||||
}),
|
||||
[id, actions, state]
|
||||
);
|
||||
return {
|
||||
id,
|
||||
actions,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
* 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, useMemo, useState } from 'react';
|
||||
import { SearchBar } from '@kbn/unified-search-plugin/public';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
BooleanRelation,
|
||||
buildEsQuery,
|
||||
isCombinedFilter,
|
||||
buildCombinedFilter,
|
||||
isFilter,
|
||||
FilterStateStore,
|
||||
} from '@kbn/es-query';
|
||||
import type { Filter, Query, TimeRange, PhraseFilter } from '@kbn/es-query';
|
||||
import { css } from '@emotion/react';
|
||||
import { getEsQueryConfig } from '@kbn/data-service';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { Graph } from '../../..';
|
||||
import { useGraphNodeExpandPopover } from './use_graph_node_expand_popover';
|
||||
import { useFetchGraphData } from '../../hooks/use_fetch_graph_data';
|
||||
import { GRAPH_INVESTIGATION_TEST_ID } from '../test_ids';
|
||||
import { ACTOR_ENTITY_ID, RELATED_ENTITY, TARGET_ENTITY_ID } from '../../common/constants';
|
||||
|
||||
const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
|
||||
|
||||
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_INVESTIGATION_FILTER,
|
||||
params: {
|
||||
query: value,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a filter to the existing list of filters based on the provided key and value.
|
||||
* It will always use the first filter in the list to build a combined filter with the new filter.
|
||||
*
|
||||
* @param dataViewId - The ID of the data view to which the filter belongs.
|
||||
* @param prev - The previous list of filters.
|
||||
* @param key - The key for the filter.
|
||||
* @param value - The value for the filter.
|
||||
* @returns A new list of filters with the added filter.
|
||||
*/
|
||||
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 (isFilter(firstFilter) && firstFilter.meta?.type !== 'custom') {
|
||||
return [
|
||||
buildCombinedFilter(BooleanRelation.OR, [firstFilter, buildPhraseFilter(key, value)], {
|
||||
id: dataViewId,
|
||||
}),
|
||||
...otherFilters,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
...buildPhraseFilter(key, value, dataViewId),
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const useGraphPopovers = (
|
||||
dataViewId: string,
|
||||
setSearchFilters: React.Dispatch<React.SetStateAction<Filter[]>>
|
||||
) => {
|
||||
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));
|
||||
},
|
||||
});
|
||||
|
||||
const openPopoverCallback = useCallback(
|
||||
(cb: Function, ...args: unknown[]) => {
|
||||
[nodeExpandPopover].forEach(({ actions: { closePopover } }) => {
|
||||
closePopover();
|
||||
});
|
||||
cb(...args);
|
||||
},
|
||||
[nodeExpandPopover]
|
||||
);
|
||||
|
||||
return { nodeExpandPopover, openPopoverCallback };
|
||||
};
|
||||
|
||||
interface GraphInvestigationProps {
|
||||
dataView: DataView;
|
||||
eventIds: string[];
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph investigation view allows the user to expand nodes and view related entities.
|
||||
*/
|
||||
export const GraphInvestigation: React.FC<GraphInvestigationProps> = memo(
|
||||
({ dataView, eventIds, timestamp = new Date().toISOString() }: GraphInvestigationProps) => {
|
||||
const [searchFilters, setSearchFilters] = useState<Filter[]>(() => []);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>({
|
||||
from: `${timestamp}||-30m`,
|
||||
to: `${timestamp}||+30m`,
|
||||
});
|
||||
|
||||
const {
|
||||
services: { uiSettings },
|
||||
} = useKibana();
|
||||
const query = useMemo(
|
||||
() =>
|
||||
buildEsQuery(
|
||||
dataView,
|
||||
[],
|
||||
[...searchFilters],
|
||||
getEsQueryConfig(uiSettings as Parameters<typeof getEsQueryConfig>[0])
|
||||
),
|
||||
[searchFilters, dataView, uiSettings]
|
||||
);
|
||||
|
||||
const { nodeExpandPopover, openPopoverCallback } = useGraphPopovers(
|
||||
dataView?.id ?? '',
|
||||
setSearchFilters
|
||||
);
|
||||
const expandButtonClickHandler = (...args: unknown[]) =>
|
||||
openPopoverCallback(nodeExpandPopover.onNodeExpandButtonClick, ...args);
|
||||
const isPopoverOpen = [nodeExpandPopover].some(({ state: { isOpen } }) => isOpen);
|
||||
const { data, refresh, isFetching } = useFetchGraphData({
|
||||
req: {
|
||||
query: {
|
||||
eventIds,
|
||||
esQuery: query,
|
||||
start: timeRange.from,
|
||||
end: timeRange.to,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
refetchOnWindowFocus: false,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
return (
|
||||
data?.nodes.map((node) => {
|
||||
const nodeHandlers =
|
||||
node.shape !== 'label' && node.shape !== 'group'
|
||||
? {
|
||||
expandButtonClick: expandButtonClickHandler,
|
||||
}
|
||||
: undefined;
|
||||
return { ...node, ...nodeHandlers };
|
||||
}) ?? []
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.nodes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
data-test-subj={GRAPH_INVESTIGATION_TEST_ID}
|
||||
direction="column"
|
||||
gutterSize="none"
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{dataView && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<SearchBar<Query>
|
||||
{...{
|
||||
appName: 'graph-investigation',
|
||||
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();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<Graph
|
||||
css={css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`}
|
||||
nodes={nodes}
|
||||
edges={data?.edges ?? []}
|
||||
interactive={true}
|
||||
isLocked={isPopoverOpen}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<nodeExpandPopover.PopoverComponent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GraphInvestigation.displayName = 'GraphInvestigation';
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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 { EuiListGroup } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ExpandPopoverListItem } from '../styles';
|
||||
import { GraphPopover } from '../../..';
|
||||
import {
|
||||
GRAPH_NODE_EXPAND_POPOVER_TEST_ID,
|
||||
GRAPH_NODE_POPOVER_SHOW_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;
|
||||
onShowRelatedEntitiesClick: () => void;
|
||||
onShowActionsByEntityClick: () => void;
|
||||
onShowActionsOnEntityClick: () => void;
|
||||
}
|
||||
|
||||
export const GraphNodeExpandPopover: React.FC<GraphNodeExpandPopoverProps> = memo(
|
||||
({
|
||||
isOpen,
|
||||
anchorElement,
|
||||
closePopover,
|
||||
onShowRelatedEntitiesClick,
|
||||
onShowActionsByEntityClick,
|
||||
onShowActionsOnEntityClick,
|
||||
}) => {
|
||||
return (
|
||||
<GraphPopover
|
||||
panelPaddingSize="s"
|
||||
anchorPosition="rightCenter"
|
||||
isOpen={isOpen}
|
||||
anchorElement={anchorElement}
|
||||
closePopover={closePopover}
|
||||
data-test-subj={GRAPH_NODE_EXPAND_POPOVER_TEST_ID}
|
||||
>
|
||||
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
|
||||
<ExpandPopoverListItem
|
||||
iconType="users"
|
||||
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showActionsByEntity', {
|
||||
defaultMessage: 'Show actions by this entity',
|
||||
})}
|
||||
onClick={onShowActionsByEntityClick}
|
||||
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID}
|
||||
/>
|
||||
<ExpandPopoverListItem
|
||||
iconType="storage"
|
||||
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showActionsOnEntity', {
|
||||
defaultMessage: 'Show actions on this entity',
|
||||
})}
|
||||
onClick={onShowActionsOnEntityClick}
|
||||
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID}
|
||||
/>
|
||||
<ExpandPopoverListItem
|
||||
iconType="visTagCloud"
|
||||
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showRelatedEvents', {
|
||||
defaultMessage: 'Show related events',
|
||||
})}
|
||||
onClick={onShowRelatedEntitiesClick}
|
||||
data-test-subj={GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID}
|
||||
/>
|
||||
</EuiListGroup>
|
||||
</GraphPopover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GraphNodeExpandPopover.displayName = 'GraphNodeExpandPopover';
|
|
@ -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 React, { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useGraphPopover } from '../../..';
|
||||
import type { ExpandButtonClickCallback, NodeProps } from '../types';
|
||||
import { GraphNodeExpandPopover } from './graph_node_expand_popover';
|
||||
|
||||
interface UseGraphNodeExpandPopoverArgs {
|
||||
onExploreRelatedEntitiesClick: (node: NodeProps) => void;
|
||||
onShowActionsByEntityClick: (node: NodeProps) => void;
|
||||
onShowActionsOnEntityClick: (node: NodeProps) => void;
|
||||
}
|
||||
|
||||
export const useGraphNodeExpandPopover = ({
|
||||
onExploreRelatedEntitiesClick,
|
||||
onShowActionsByEntityClick,
|
||||
onShowActionsOnEntityClick,
|
||||
}: UseGraphNodeExpandPopoverArgs) => {
|
||||
const { id, state, actions } = useGraphPopover('node-expand-popover');
|
||||
const { openPopover, closePopover } = actions;
|
||||
|
||||
const selectedNode = useRef<NodeProps | null>(null);
|
||||
const unToggleCallbackRef = useRef<(() => void) | null>(null);
|
||||
const [pendingOpen, setPendingOpen] = useState<{
|
||||
node: NodeProps;
|
||||
el: HTMLElement;
|
||||
unToggleCallback: () => void;
|
||||
} | null>(null);
|
||||
|
||||
// Handler to close the popover, reset selected node and unToggle callback
|
||||
const closePopoverHandler = useCallback(() => {
|
||||
selectedNode.current = null;
|
||||
unToggleCallbackRef.current?.();
|
||||
unToggleCallbackRef.current = null;
|
||||
closePopover();
|
||||
}, [closePopover]);
|
||||
|
||||
/**
|
||||
* Handles the click event on the node expand button.
|
||||
* Closes the current popover if open and sets the pending open state
|
||||
* if the clicked node is different from the currently selected node.
|
||||
*/
|
||||
const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback(
|
||||
(e, node, unToggleCallback) => {
|
||||
// Close the current popover if open
|
||||
closePopoverHandler();
|
||||
|
||||
if (selectedNode.current?.id !== node.id) {
|
||||
// Set the pending open state
|
||||
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
|
||||
}
|
||||
},
|
||||
[closePopoverHandler]
|
||||
);
|
||||
|
||||
// PopoverComponent is a memoized component that renders the GraphNodeExpandPopover
|
||||
// It handles the display of the popover and the actions that can be performed on the node
|
||||
const PopoverComponent = memo(() => (
|
||||
<GraphNodeExpandPopover
|
||||
isOpen={state.isOpen}
|
||||
anchorElement={state.anchorElement}
|
||||
closePopover={closePopoverHandler}
|
||||
onShowRelatedEntitiesClick={() => {
|
||||
onExploreRelatedEntitiesClick(selectedNode.current as NodeProps);
|
||||
closePopoverHandler();
|
||||
}}
|
||||
onShowActionsByEntityClick={() => {
|
||||
onShowActionsByEntityClick(selectedNode.current as NodeProps);
|
||||
closePopoverHandler();
|
||||
}}
|
||||
onShowActionsOnEntityClick={() => {
|
||||
onShowActionsOnEntityClick(selectedNode.current as NodeProps);
|
||||
closePopoverHandler();
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Open pending popover if the popover is not open
|
||||
// This block checks if there is a pending popover to be opened.
|
||||
// If the popover is not currently open and there is a pending popover,
|
||||
// it sets the selected node, stores the unToggle callback, and opens the popover.
|
||||
if (!state.isOpen && pendingOpen) {
|
||||
const { node, el, unToggleCallback } = pendingOpen;
|
||||
|
||||
selectedNode.current = node;
|
||||
unToggleCallbackRef.current = unToggleCallback;
|
||||
openPopover(el);
|
||||
|
||||
setPendingOpen(null);
|
||||
}
|
||||
|
||||
return {
|
||||
onNodeExpandButtonClick,
|
||||
PopoverComponent,
|
||||
id,
|
||||
actions: {
|
||||
...actions,
|
||||
closePopover: closePopoverHandler,
|
||||
},
|
||||
state,
|
||||
};
|
||||
};
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
export { Graph } from './graph/graph';
|
||||
export { GraphInvestigation } from './graph_investigation/graph_investigation';
|
||||
export { GraphPopover } from './graph/graph_popover';
|
||||
export { useGraphPopover } from './graph/use_graph_popover';
|
||||
export type { GraphProps } from './graph/graph';
|
||||
|
|
|
@ -35,6 +35,7 @@ export const NodeExpandButton = ({ x, y, onClick }: NodeExpandButtonProps) => {
|
|||
onClick={onClickHandler}
|
||||
iconSize="m"
|
||||
aria-label="Open or close node actions"
|
||||
data-test-subj="nodeExpandButton"
|
||||
/>
|
||||
</StyledNodeExpandButton>
|
||||
);
|
||||
|
|
|
@ -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<RoundedEuiIconProps> = ({ color, background, ...r
|
|||
);
|
||||
|
||||
export const ExpandPopoverListItem: React.FC<
|
||||
Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
|
||||
CommonProps & Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
|
||||
> = (props) => {
|
||||
const { iconType, label, onClick, ...rest } = props;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
<EuiListGroupItem
|
||||
{...rest}
|
||||
icon={
|
||||
props.iconType ? (
|
||||
<RoundedEuiIcon color="primary" background="primary" type={props.iconType} size="s" />
|
||||
iconType ? (
|
||||
<RoundedEuiIcon color="primary" background="primary" type={iconType} size="s" />
|
||||
) : undefined
|
||||
}
|
||||
label={
|
||||
<EuiText size="s" color={euiTheme.colors.primaryText}>
|
||||
{props.label}
|
||||
{label}
|
||||
</EuiText>
|
||||
}
|
||||
onClick={props.onClick}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const PREFIX = 'cloudSecurityGraph' as const;
|
||||
|
||||
export const GRAPH_INVESTIGATION_TEST_ID = `${PREFIX}GraphInvestigation` as const;
|
||||
export const GRAPH_NODE_EXPAND_POPOVER_TEST_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}GraphNodeExpandPopover` as const;
|
||||
export const GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}ExploreRelatedEntities` as const;
|
||||
export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}ShowActionsByEntity` as const;
|
||||
export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity` as const;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { useFetchGraphData } from './use_fetch_graph_data';
|
|
@ -13,15 +13,22 @@ const mockUseQuery = jest.fn();
|
|||
jest.mock('@tanstack/react-query', () => {
|
||||
return {
|
||||
useQuery: (...args: unknown[]) => mockUseQuery(...args),
|
||||
useQueryClient: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: true,
|
||||
keepPreviousData: false,
|
||||
};
|
||||
|
||||
describe('useFetchGraphData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should pass default options when options are not provided', () => {
|
||||
it('should pass default options when options are not provided', () => {
|
||||
renderHook(() => {
|
||||
return useFetchGraphData({
|
||||
req: {
|
||||
|
@ -36,12 +43,11 @@ describe('useFetchGraphData', () => {
|
|||
|
||||
expect(mockUseQuery.mock.calls).toHaveLength(1);
|
||||
expect(mockUseQuery.mock.calls[0][2]).toEqual({
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: true,
|
||||
...defaultOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should should not be enabled when enabled set to false', () => {
|
||||
it('should not be enabled when enabled set to false', () => {
|
||||
renderHook(() => {
|
||||
return useFetchGraphData({
|
||||
req: {
|
||||
|
@ -59,12 +65,12 @@ describe('useFetchGraphData', () => {
|
|||
|
||||
expect(mockUseQuery.mock.calls).toHaveLength(1);
|
||||
expect(mockUseQuery.mock.calls[0][2]).toEqual({
|
||||
...defaultOptions,
|
||||
enabled: false,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('Should should not be refetchOnWindowFocus when refetchOnWindowFocus set to false', () => {
|
||||
it('should not be refetchOnWindowFocus when refetchOnWindowFocus set to false', () => {
|
||||
renderHook(() => {
|
||||
return useFetchGraphData({
|
||||
req: {
|
||||
|
@ -82,8 +88,31 @@ describe('useFetchGraphData', () => {
|
|||
|
||||
expect(mockUseQuery.mock.calls).toHaveLength(1);
|
||||
expect(mockUseQuery.mock.calls[0][2]).toEqual({
|
||||
enabled: true,
|
||||
...defaultOptions,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keepPreviousData when keepPreviousData set to true', () => {
|
||||
renderHook(() => {
|
||||
return useFetchGraphData({
|
||||
req: {
|
||||
query: {
|
||||
eventIds: [],
|
||||
start: '2021-09-01T00:00:00.000Z',
|
||||
end: '2021-09-01T23:59:59.999Z',
|
||||
},
|
||||
},
|
||||
options: {
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUseQuery.mock.calls).toHaveLength(1);
|
||||
expect(mockUseQuery.mock.calls[0][2]).toEqual({
|
||||
...defaultOptions,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,14 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
GraphRequest,
|
||||
GraphResponse,
|
||||
} from '@kbn/cloud-security-posture-common/types/graph/latest';
|
||||
import { useMemo } from 'react';
|
||||
import { EVENT_GRAPH_VISUALIZATION_API } from '../../../../../common/constants';
|
||||
import { useHttp } from '../../../../common/lib/kibana';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { EVENT_GRAPH_VISUALIZATION_API } from '../common/constants';
|
||||
|
||||
/**
|
||||
* Interface for the input parameters of the useFetchGraphData hook.
|
||||
|
@ -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,16 +80,23 @@ export const useFetchGraphData = ({
|
|||
req,
|
||||
options,
|
||||
}: UseFetchGraphDataParams): UseFetchGraphDataResult => {
|
||||
const { eventIds, start, end, esQuery } = req.query;
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
const { esQuery, eventIds, start, end } = req.query;
|
||||
const {
|
||||
services: { http },
|
||||
} = useKibana();
|
||||
const QUERY_KEY = useMemo(
|
||||
() => ['useFetchGraphData', eventIds, start, end, esQuery],
|
||||
[end, esQuery, eventIds, start]
|
||||
);
|
||||
|
||||
const { isLoading, isError, data } = useQuery<GraphResponse>(
|
||||
const { isLoading, isError, data, isFetching } = useQuery<GraphResponse>(
|
||||
QUERY_KEY,
|
||||
() => {
|
||||
if (!http) {
|
||||
return Promise.reject(new Error('Http service is not available'));
|
||||
}
|
||||
|
||||
return http.post<GraphResponse>(EVENT_GRAPH_VISUALIZATION_API, {
|
||||
version: '1',
|
||||
body: JSON.stringify(req),
|
||||
|
@ -85,12 +105,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);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -12,7 +12,13 @@
|
|||
],
|
||||
"kbn_references": [
|
||||
"@kbn/cloud-security-posture-common",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/unified-search-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/data-service",
|
||||
"@kbn/i18n",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ 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';
|
||||
|
||||
export const defineGraphRoute = (router: CspRouter) =>
|
||||
|
@ -39,10 +39,11 @@ export const defineGraphRoute = (router: CspRouter) =>
|
|||
},
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
async (context: CspRequestHandlerContext, request, response) => {
|
||||
const cspContext = await context.csp;
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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<CoreRequestHandlerContext>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -282,8 +282,6 @@ export const PINNED_EVENT_URL = '/api/pinned_event' as const;
|
|||
export const SOURCERER_API_URL = '/internal/security_solution/sourcerer' as const;
|
||||
export const RISK_SCORE_INDEX_STATUS_API_URL = '/internal/risk_score/index_status' as const;
|
||||
|
||||
export const EVENT_GRAPH_VISUALIZATION_API = '/internal/cloud_security_posture/graph' as const;
|
||||
|
||||
/**
|
||||
* Default signals index key for kibana.dev.yml
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { css } from '@emotion/css';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
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 { useGraphPreview } from '../../shared/hooks/use_graph_preview';
|
||||
|
||||
const GraphInvestigationLazy = React.lazy(() =>
|
||||
import('@kbn/cloud-security-posture-graph').then((module) => ({
|
||||
default: module.GraphInvestigation,
|
||||
}))
|
||||
);
|
||||
|
||||
export const GRAPH_ID = 'graph-visualization' as const;
|
||||
|
||||
/**
|
||||
* 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 { getFieldsData, dataAsNestedObject } = useDocumentDetailsContext();
|
||||
const { eventIds, timestamp } = useGraphPreview({
|
||||
getFieldsData,
|
||||
ecsData: dataAsNestedObject,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test-subj={GRAPH_VISUALIZATION_TEST_ID}
|
||||
css={css`
|
||||
height: calc(100vh - 250px);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
`}
|
||||
>
|
||||
{dataView && (
|
||||
<React.Suspense fallback={<EuiLoadingSpinner />}>
|
||||
<GraphInvestigationLazy dataView={dataView} eventIds={eventIds} timestamp={timestamp} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GraphVisualization.displayName = 'GraphVisualization';
|
|
@ -11,6 +11,7 @@ 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;
|
||||
|
||||
/* Insights tab */
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -11,12 +11,14 @@ import type { EuiButtonGroupOptionProps } from '@elastic/eui/src/components/butt
|
|||
import { useExpandableFlyoutApi, useExpandableFlyoutState } from '@kbn/expandable-flyout';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useDocumentDetailsContext } from '../../shared/context';
|
||||
import { useWhichFlyout } from '../../shared/hooks/use_which_flyout';
|
||||
import { DocumentDetailsAnalyzerPanelKey } from '../../shared/constants/panel_keys';
|
||||
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 +29,9 @@ 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_ID, GraphVisualization } from '../components/graph_visualization';
|
||||
import { useGraphPreview } from '../../shared/hooks/use_graph_preview';
|
||||
import { GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE } from '../../shared/constants/experimental_features';
|
||||
|
||||
const visualizeButtons: EuiButtonGroupOptionProps[] = [
|
||||
{
|
||||
|
@ -51,11 +56,39 @@ const visualizeButtons: EuiButtonGroupOptionProps[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const graphVisualizationButton: EuiButtonGroupOptionProps = {
|
||||
id: GRAPH_ID,
|
||||
iconType: 'beaker',
|
||||
iconSide: 'right',
|
||||
toolTipProps: {
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.left.visualize.graphVisualizationButton.technicalPreviewLabel"
|
||||
defaultMessage="Technical Preview"
|
||||
/>
|
||||
),
|
||||
},
|
||||
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: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.left.visualize.graphVisualizationButtonLabel"
|
||||
defaultMessage="Graph view"
|
||||
/>
|
||||
),
|
||||
'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 +119,22 @@ export const VisualizeTab = memo(() => {
|
|||
}
|
||||
}, [panels.left?.path?.subTab]);
|
||||
|
||||
// Decide whether to show the graph preview or not
|
||||
const { hasGraphRepresentation } = useGraphPreview({
|
||||
getFieldsData,
|
||||
ecsData: dataAsNestedObject,
|
||||
});
|
||||
|
||||
const isGraphFeatureEnabled = useIsExperimentalFeatureEnabled(
|
||||
GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE
|
||||
);
|
||||
|
||||
const options = [...visualizeButtons];
|
||||
|
||||
if (hasGraphRepresentation && isGraphFeatureEnabled) {
|
||||
options.push(graphVisualizationButton);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiButtonGroup
|
||||
|
@ -97,7 +146,7 @@ export const VisualizeTab = memo(() => {
|
|||
defaultMessage: 'Visualize options',
|
||||
}
|
||||
)}
|
||||
options={visualizeButtons}
|
||||
options={options}
|
||||
idSelected={activeVisualizationId}
|
||||
onChange={(id) => onChangeCompressed(id)}
|
||||
buttonSize="compressed"
|
||||
|
@ -107,6 +156,7 @@ export const VisualizeTab = memo(() => {
|
|||
<EuiSpacer size="m" />
|
||||
{activeVisualizationId === SESSION_VIEW_ID && <SessionView />}
|
||||
{activeVisualizationId === ANALYZE_GRAPH_ID && <AnalyzeGraph />}
|
||||
{activeVisualizationId === GRAPH_ID && <GraphVisualization />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -36,10 +36,19 @@ describe('<GraphPreview />', () => {
|
|||
});
|
||||
|
||||
it('shows graph preview correctly when data is loaded', async () => {
|
||||
const graphProps = {
|
||||
const graphProps: GraphPreviewProps = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { nodes: [], edges: [] },
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
color: 'primary',
|
||||
shape: 'ellipse',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
|
||||
const { findByTestId } = renderGraphPreview(mockContextValue, graphProps);
|
||||
|
@ -69,4 +78,15 @@ describe('<GraphPreview />', () => {
|
|||
|
||||
expect(getByText(ERROR_MESSAGE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when data is empty', () => {
|
||||
const graphProps = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
const { getByText } = renderGraphPreview(mockContextValue, graphProps);
|
||||
|
||||
expect(getByText(ERROR_MESSAGE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
@ -71,13 +71,19 @@ export const GraphPreview: React.FC<GraphPreviewProps> = memo(
|
|||
|
||||
return isLoading ? (
|
||||
<LoadingComponent />
|
||||
) : isError ? (
|
||||
) : isError || memoizedNodes.length === 0 ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.errorDescription"
|
||||
defaultMessage="An error is preventing this alert from being visualized."
|
||||
/>
|
||||
) : (
|
||||
<React.Suspense fallback={<LoadingComponent />}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<EuiPanel>
|
||||
<LoadingComponent />
|
||||
</EuiPanel>
|
||||
}
|
||||
>
|
||||
<GraphLazy
|
||||
css={css`
|
||||
height: 300px;
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import React from 'react';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { mockContextValue } from '../../shared/mocks/mock_context';
|
||||
import { GraphPreviewContainer } from './graph_preview_container';
|
||||
import { GRAPH_PREVIEW_TEST_ID } from './test_ids';
|
||||
import { useGraphPreview } from '../hooks/use_graph_preview';
|
||||
import { useFetchGraphData } from '../hooks/use_fetch_graph_data';
|
||||
import { useGraphPreview } from '../../shared/hooks/use_graph_preview';
|
||||
import {
|
||||
EXPANDABLE_PANEL_CONTENT_TEST_ID,
|
||||
EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID,
|
||||
|
@ -21,9 +21,25 @@ import {
|
|||
EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID,
|
||||
EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID,
|
||||
} from '../../../shared/components/test_ids';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
|
||||
jest.mock('../hooks/use_graph_preview');
|
||||
jest.mock('../hooks/use_fetch_graph_data', () => ({
|
||||
const mockUseUiSetting = jest.fn().mockReturnValue([true]);
|
||||
jest.mock('@kbn/kibana-react-plugin/public', () => {
|
||||
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
|
||||
return {
|
||||
...original,
|
||||
useUiSetting$: () => mockUseUiSetting(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
|
||||
|
||||
jest.mock('../../shared/hooks/use_graph_preview');
|
||||
jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({
|
||||
useFetchGraphData: jest.fn(),
|
||||
}));
|
||||
const mockUseFetchGraphData = useFetchGraphData as jest.Mock;
|
||||
|
@ -43,16 +59,25 @@ const renderGraphPreview = (context = mockContextValue) =>
|
|||
</TestProviders>
|
||||
);
|
||||
|
||||
const DEFAULT_NODES = [
|
||||
{
|
||||
id: '1',
|
||||
color: 'primary',
|
||||
shape: 'ellipse',
|
||||
},
|
||||
];
|
||||
|
||||
describe('<GraphPreviewContainer />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should render component and link in header', async () => {
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { nodes: [], edges: [] },
|
||||
data: { nodes: DEFAULT_NODES, edges: [] },
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
@ -60,7 +85,164 @@ describe('<GraphPreviewContainer />', () => {
|
|||
(useGraphPreview as jest.Mock).mockReturnValue({
|
||||
timestamp,
|
||||
eventIds: [],
|
||||
isAuditLog: true,
|
||||
hasGraphRepresentation: true,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview();
|
||||
|
||||
// Using findByTestId to wait for the component to be rendered because it is a lazy loaded component
|
||||
expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockUseFetchGraphData).toHaveBeenCalled();
|
||||
expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({
|
||||
req: {
|
||||
query: {
|
||||
eventIds: [],
|
||||
start: `${timestamp}||-30m`,
|
||||
end: `${timestamp}||+30m`,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component and without link in header in preview panel', async () => {
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { nodes: DEFAULT_NODES, edges: [] },
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
(useGraphPreview as jest.Mock).mockReturnValue({
|
||||
timestamp,
|
||||
eventIds: [],
|
||||
hasGraphRepresentation: true,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview({
|
||||
...mockContextValue,
|
||||
isPreviewMode: true,
|
||||
});
|
||||
|
||||
// Using findByTestId to wait for the component to be rendered because it is a lazy loaded component
|
||||
expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(mockUseFetchGraphData).toHaveBeenCalled();
|
||||
expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({
|
||||
req: {
|
||||
query: {
|
||||
eventIds: [],
|
||||
start: `${timestamp}||-30m`,
|
||||
end: `${timestamp}||+30m`,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component and without link in header in rule preview', async () => {
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { nodes: DEFAULT_NODES, edges: [] },
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
(useGraphPreview as jest.Mock).mockReturnValue({
|
||||
timestamp,
|
||||
eventIds: [],
|
||||
hasGraphRepresentation: true,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview({
|
||||
...mockContextValue,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
// Using findByTestId to wait for the component to be rendered because it is a lazy loaded component
|
||||
expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(mockUseFetchGraphData).toHaveBeenCalled();
|
||||
expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({
|
||||
req: {
|
||||
query: {
|
||||
eventIds: [],
|
||||
start: `${timestamp}||-30m`,
|
||||
end: `${timestamp}||+30m`,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component and without link in header when expanding flyout feature is disabled', async () => {
|
||||
mockUseUiSetting.mockReturnValue([false]);
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { nodes: DEFAULT_NODES, edges: [] },
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
(useGraphPreview as jest.Mock).mockReturnValue({
|
||||
timestamp,
|
||||
eventIds: [],
|
||||
hasGraphRepresentation: true,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview();
|
||||
|
@ -98,21 +280,55 @@ describe('<GraphPreviewContainer />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should not render when graph data is not available', () => {
|
||||
it('should not render when graph data is not available', async () => {
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
(useGraphPreview as jest.Mock).mockReturnValue({
|
||||
isAuditLog: false,
|
||||
timestamp,
|
||||
eventIds: [],
|
||||
hasGraphRepresentation: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = renderGraphPreview();
|
||||
const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview();
|
||||
|
||||
// Using findByTestId to wait for the component to be rendered because it is a lazy loaded component
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
await findByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID))
|
||||
).toBeInTheDocument();
|
||||
expect(mockUseFetchGraphData).toHaveBeenCalled();
|
||||
expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({
|
||||
req: {
|
||||
query: {
|
||||
eventIds: [],
|
||||
start: `${timestamp}||-30m`,
|
||||
end: `${timestamp}||+30m`,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
enabled: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,23 +7,48 @@
|
|||
|
||||
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 { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks';
|
||||
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 { 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,
|
||||
isPreview,
|
||||
isPreviewMode,
|
||||
} = useDocumentDetailsContext();
|
||||
|
||||
const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>(
|
||||
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING
|
||||
);
|
||||
const allowFlyoutExpansion = visualizationInFlyoutEnabled && !isPreviewMode && !isPreview;
|
||||
|
||||
const { navigateToGraphVisualization } = useNavigateToGraphVisualization({
|
||||
eventId,
|
||||
indexName,
|
||||
isFlyoutOpen: true,
|
||||
scopeId,
|
||||
});
|
||||
|
||||
const {
|
||||
eventIds,
|
||||
timestamp = new Date().toISOString(),
|
||||
isAuditLog,
|
||||
hasGraphRepresentation,
|
||||
} = useGraphPreview({
|
||||
getFieldsData,
|
||||
ecsData: dataAsNestedObject,
|
||||
|
@ -39,35 +64,64 @@ export const GraphPreviewContainer: React.FC = () => {
|
|||
},
|
||||
},
|
||||
options: {
|
||||
enabled: isAuditLog,
|
||||
enabled: hasGraphRepresentation,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
isAuditLog && (
|
||||
<ExpandablePanel
|
||||
header={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewTitle"
|
||||
defaultMessage="Graph preview"
|
||||
/>
|
||||
),
|
||||
iconType: 'indexMapping',
|
||||
}}
|
||||
data-test-subj={GRAPH_PREVIEW_TEST_ID}
|
||||
content={
|
||||
!isLoading && !isError
|
||||
? {
|
||||
paddingSize: 'none',
|
||||
<ExpandablePanel
|
||||
header={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewTitle"
|
||||
defaultMessage="Graph preview"
|
||||
/>
|
||||
),
|
||||
headerContent: (
|
||||
<EuiBetaBadge
|
||||
alignment="middle"
|
||||
iconType="beaker"
|
||||
data-test-subj="graphPreviewBetaBadge"
|
||||
label={i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.visualizations.graphPreview.technicalPreviewLabel',
|
||||
{
|
||||
defaultMessage: 'Technical Preview',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<GraphPreview isLoading={isLoading} isError={isError} data={data} />
|
||||
</ExpandablePanel>
|
||||
)
|
||||
)}
|
||||
tooltipContent={i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.visualizations.graphPreview.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.',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
),
|
||||
iconType: allowFlyoutExpansion ? 'arrowStart' : 'indexMapping',
|
||||
...(allowFlyoutExpansion && {
|
||||
link: {
|
||||
callback: navigateToGraphVisualization,
|
||||
tooltip: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewOpenGraphTooltip"
|
||||
defaultMessage="Expand graph"
|
||||
/>
|
||||
),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
data-test-subj={GRAPH_PREVIEW_TEST_ID}
|
||||
content={
|
||||
!isLoading && !isError
|
||||
? {
|
||||
paddingSize: 'none',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<GraphPreview isLoading={isLoading} isError={isError} data={data} />
|
||||
</ExpandablePanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React from 'react';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks';
|
||||
import {
|
||||
ANALYZER_PREVIEW_TEST_ID,
|
||||
SESSION_PREVIEW_TEST_ID,
|
||||
|
@ -25,9 +26,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 { useGraphPreview } from '../../shared/hooks/use_graph_preview';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useGraphPreview } from '../hooks/use_graph_preview';
|
||||
import { useFetchGraphData } from '../hooks/use_fetch_graph_data';
|
||||
|
||||
jest.mock('../hooks/use_expand_section');
|
||||
jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({
|
||||
|
@ -53,6 +53,7 @@ jest.mock(
|
|||
jest.mock(
|
||||
'../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'
|
||||
);
|
||||
|
||||
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
@ -67,11 +68,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('@kbn/cloud-security-posture-graph/src/hooks', () => ({
|
||||
useFetchGraphData: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -95,6 +96,7 @@ const renderVisualizationsSection = (contextValue = panelContextValue) =>
|
|||
|
||||
describe('<VisualizationsSection />', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUiSetting.mockReturnValue([false]);
|
||||
mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] });
|
||||
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
|
||||
loading: false,
|
||||
|
@ -103,7 +105,7 @@ describe('<VisualizationsSection />', () => {
|
|||
statsNodes: undefined,
|
||||
});
|
||||
mockUseGraphPreview.mockReturnValue({
|
||||
isAuditLog: true,
|
||||
hasGraphRepresentation: true,
|
||||
});
|
||||
mockUseFetchGraphData.mockReturnValue({
|
||||
isLoading: false,
|
||||
|
@ -136,6 +138,7 @@ describe('<VisualizationsSection />', () => {
|
|||
});
|
||||
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
mockUseUiSetting.mockReturnValue([false]);
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderVisualizationsSection();
|
||||
|
@ -148,10 +151,31 @@ describe('<VisualizationsSection />', () => {
|
|||
|
||||
it('should render the graph preview component if the feature is enabled', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
mockUseUiSetting.mockReturnValue([true]);
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
|
||||
|
||||
const { getByTestId } = renderVisualizationsSection();
|
||||
|
||||
expect(getByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the graph preview component if the experimental feature is disabled', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
mockUseUiSetting.mockReturnValue([true]);
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = renderVisualizationsSection();
|
||||
|
||||
expect(queryByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the graph preview component if the flyout feature is disabled', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
mockUseUiSetting.mockReturnValue([false]);
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
|
||||
|
||||
const { queryByTestId } = renderVisualizationsSection();
|
||||
|
||||
expect(queryByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,15 +8,18 @@
|
|||
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 { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
|
||||
import { GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE } from '../../shared/constants/experimental_features';
|
||||
|
||||
const KEY = 'visualizations';
|
||||
|
||||
|
@ -25,18 +28,25 @@ 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$<boolean>(
|
||||
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING
|
||||
);
|
||||
|
||||
const isGraphFeatureEnabled = useIsExperimentalFeatureEnabled(
|
||||
GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE
|
||||
);
|
||||
|
||||
// Decide whether to show the graph preview or not
|
||||
const { isAuditLog: isGraphPreviewEnabled } = useGraphPreview({
|
||||
const { hasGraphRepresentation } = useGraphPreview({
|
||||
getFieldsData,
|
||||
ecsData: dataAsNestedObject,
|
||||
});
|
||||
|
||||
const shouldShowGraphPreview =
|
||||
visualizationInFlyoutEnabled && isGraphFeatureEnabled && hasGraphRepresentation;
|
||||
|
||||
return (
|
||||
<ExpandableSection
|
||||
expanded={expanded}
|
||||
|
@ -52,7 +62,7 @@ export const VisualizationsSection = memo(() => {
|
|||
<SessionPreviewContainer />
|
||||
<EuiSpacer />
|
||||
<AnalyzerPreviewContainer />
|
||||
{graphVisualizationInFlyoutEnabled && isGraphPreviewEnabled && (
|
||||
{shouldShowGraphPreview && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<GraphPreviewContainer />
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** This security solution experimental feature allows user to enable/disable the graph visualization in Flyout feature (depends on securitySolution:enableVisualizationsInFlyout) */
|
||||
export const GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE =
|
||||
'graphVisualizationInFlyoutEnabled' as const;
|
|
@ -9,18 +9,30 @@ import type { RenderHookResult } from '@testing-library/react';
|
|||
import { renderHook } from '@testing-library/react';
|
||||
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<UseGraphPreviewResult, UseGraphPreviewParams>;
|
||||
|
||||
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;
|
||||
expect(isAuditLog).toEqual(false);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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 { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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;
|
||||
expect(isAuditLog).toEqual(false);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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;
|
||||
expect(isAuditLog).toEqual(false);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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;
|
||||
expect(isAuditLog).toEqual(false);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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;
|
||||
expect(isAuditLog).toEqual(true);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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;
|
||||
expect(isAuditLog).toEqual(true);
|
||||
const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } =
|
||||
hookResult.result.current;
|
||||
expect(hasGraphRepresentation).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']);
|
||||
});
|
||||
});
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
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,15 +40,20 @@ export interface UseGraphPreviewResult {
|
|||
*/
|
||||
actorIds: string[];
|
||||
|
||||
/**
|
||||
* Array of target entity IDs associated with the alert
|
||||
*/
|
||||
targetIds: string[];
|
||||
|
||||
/**
|
||||
* Action associated with the event
|
||||
*/
|
||||
action?: string[];
|
||||
|
||||
/**
|
||||
* Boolean indicating if the event is an audit log (contains event ids, actor ids and action)
|
||||
* Boolean indicating if the event is has a graph representation (contains event ids, actor ids and action)
|
||||
*/
|
||||
isAuditLog: boolean;
|
||||
hasGraphRepresentation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -64,9 +69,14 @@ 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;
|
||||
const hasGraphRepresentation =
|
||||
Boolean(timestamp) &&
|
||||
Boolean(action?.length) &&
|
||||
actorIds.length > 0 &&
|
||||
eventIds.length > 0 &&
|
||||
targetIds.length > 0;
|
||||
|
||||
return { timestamp, eventIds, actorIds, action, isAuditLog };
|
||||
return { timestamp, eventIds, actorIds, action, targetIds, hasGraphRepresentation };
|
||||
};
|
|
@ -49,7 +49,7 @@ export interface UseNavigateToAnalyzerResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Hook that returns the a callback to navigate to the analyzer in the flyout
|
||||
* Hook that returns a callback to navigate to the analyzer in the flyout
|
||||
*/
|
||||
export const useNavigateToAnalyzer = ({
|
||||
isFlyoutOpen,
|
||||
|
|
|
@ -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 { useNavigateToGraphVisualization } from './use_navigate_to_graph_visualization';
|
||||
import { GRAPH_ID } from '../../left/components/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_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_ID,
|
||||
},
|
||||
params: {
|
||||
id: eventId,
|
||||
indexName,
|
||||
scopeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { DocumentEventTypes } from '../../../../common/lib/telemetry';
|
||||
import { GRAPH_ID } from '../../left/components/graph_visualization';
|
||||
|
||||
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<string> | undefined;
|
||||
/**
|
||||
* Scope id of the page
|
||||
*/
|
||||
scopeId: string;
|
||||
}
|
||||
|
||||
export interface UseNavigateToGraphVisualizationResult {
|
||||
/**
|
||||
* Callback to open analyzer in visualize tab
|
||||
*/
|
||||
navigateToGraphVisualization: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns 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_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]);
|
||||
};
|
|
@ -42,7 +42,7 @@ export interface UseNavigateToSessionViewResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Hook that returns the a callback to navigate to session view in the flyout
|
||||
* Hook that returns a callback to navigate to session view in the flyout
|
||||
*/
|
||||
export const useNavigateToSessionView = ({
|
||||
isFlyoutOpen,
|
||||
|
|
|
@ -5,13 +5,27 @@
|
|||
* 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'),
|
||||
`--logging.loggers=${JSON.stringify([
|
||||
...getKibanaCliLoggers(baseConfig.get('kbnTestServer.serverArgs')),
|
||||
{
|
||||
name: 'plugins.cloudSecurityPosture',
|
||||
level: 'all',
|
||||
appenders: ['default'],
|
||||
},
|
||||
])}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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', () => {
|
||||
// TODO: fix once feature flag is enabled for the API
|
||||
describe.skip('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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -38,6 +38,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`,
|
||||
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
|
||||
'graphVisualizationInFlyoutEnabled',
|
||||
])}`,
|
||||
|
|
|
@ -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"
|
||||
],
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 NODE_EXPAND_BUTTON_TEST_ID = 'nodeExpandButton';
|
||||
const GRAPH_INVESTIGATION_TEST_ID = 'cloudSecurityGraphGraphInvestigation';
|
||||
const GRAPH_NODE_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphNodeExpandPopover`;
|
||||
const GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ExploreRelatedEntities`;
|
||||
const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsByEntity`;
|
||||
const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity`;
|
||||
type Filter = Parameters<FilterBarService['addFilter']>[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<void> {
|
||||
await this.testSubjects.click(GRAPH_PREVIEW_TITLE_LINK_TEST_ID);
|
||||
}
|
||||
|
||||
async waitGraphIsLoaded(): Promise<void> {
|
||||
await this.testSubjects.existOrFail(GRAPH_INVESTIGATION_TEST_ID, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async assertGraphNodesNumber(expected: number): Promise<void> {
|
||||
await this.waitGraphIsLoaded();
|
||||
const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_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<WebElementWrapper> {
|
||||
await this.waitGraphIsLoaded();
|
||||
const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.clickEditFilter(filterIdx);
|
||||
|
||||
const filterPreview = await this.filterBar.getFilterEditorPreview();
|
||||
expect(filterPreview).to.be(expected);
|
||||
|
||||
await this.filterBar.ensureFieldEditorModalIsClosed();
|
||||
}
|
||||
|
||||
async clickEditFilter(filterIdx: number): Promise<void> {
|
||||
await this.filterBar.clickEditFilterById(filterIdx.toString());
|
||||
}
|
||||
|
||||
async clearAllFilters(): Promise<void> {
|
||||
await this.testSubjects.click(`${GRAPH_INVESTIGATION_TEST_ID} > showQueryBarMenu`);
|
||||
await this.testSubjects.click('filter-sets-removeAllFilters');
|
||||
await this.pageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async addFilter(filter: Filter): Promise<void> {
|
||||
await this.testSubjects.click(`${GRAPH_INVESTIGATION_TEST_ID} > addFilter`);
|
||||
await this.filterBar.createFilter(filter);
|
||||
await this.testSubjects.scrollIntoView('saveFilter');
|
||||
await this.testSubjects.clickWhenNotDisabled('saveFilter');
|
||||
await this.pageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> => {
|
||||
const refreshButton = await this.testSubjects.find('querySubmitButton');
|
||||
|
||||
return (await refreshButton.isDisplayed()) && (await refreshButton.isEnabled());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async ensureOnNetworkEventsPage(): Promise<void> {
|
||||
await this.testSubjects.existOrFail('network-details-headline');
|
||||
}
|
||||
|
||||
async waitForListToHaveEvents(timeoutMs?: number): Promise<void> {
|
||||
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<boolean> => {
|
||||
await this.clickRefresh();
|
||||
|
||||
const allEventRowsInner = await this.testSubjects.findService.allByCssSelector(
|
||||
EVENTS_TABLE_ROW_CSS_SELECTOR
|
||||
);
|
||||
|
||||
return Boolean(allEventRowsInner.length);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
flyout = {
|
||||
expandVisualizations: async (): Promise<void> => {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue