mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cloud Security] Added investigate in timeline to graph investigation (#205264)
## Summary This PR includes the following changes to the graph investigation component: - Add "Investigate in timeline" button to the graph in the expanded flyout (applying the same filters to the timeline) - Move graph control buttons to the right and align their design https://github.com/user-attachments/assets/6e7c59a7-150d-4046-8e3d-14790b8f6d4d <details> <summary>New storybook stories 📹 </summary> https://github.com/user-attachments/assets/adf64f7f-3bd3-499e-b9ba-f8040df729e7 </details> **How to test:** To test this PR using storybook (alternatively access to storybooks attached to this build) ``` yarn storybook cloud_security_posture_packages ``` To test e2e: - 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 year) To run FTR tests: ``` yarn test:ftr:server --config x-pack/test/cloud_security_posture_functional/config.ts yarn test:ftr:runner --config x-pack/test/cloud_security_posture_functional/config.ts --grep="Graph visualization" ``` ### 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) - [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 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
eafa15bba6
commit
301f91da2b
42 changed files with 1530 additions and 199 deletions
|
@ -17,8 +17,8 @@ export const storybookAliases = {
|
|||
canvas: 'x-pack/platform/plugins/private/canvas/storybook',
|
||||
cases: 'src/platform/packages/shared/kbn-cases-components/.storybook',
|
||||
cell_actions: 'src/platform/packages/shared/kbn-cell-actions/.storybook',
|
||||
cloud_security_posture_packages:
|
||||
'x-pack/solutions/security/packages/kbn-cloud-security-posture/.storybook',
|
||||
cloud_security_posture_graph:
|
||||
'x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/.storybook',
|
||||
cloud: 'src/platform/packages/shared/cloud/.storybook',
|
||||
coloring: 'packages/kbn-coloring/.storybook',
|
||||
language_documentation_popover:
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
# @kbn/cloud-security-posture-storybook-config
|
||||
|
||||
Storybook configuration used by `yarn storybook`. Refer to `@kbn/storybook` for more information.
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "@kbn/cloud-security-posture-storybook-config",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0"
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "../../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "@kbn/ambient-storybook-types"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"kbn_references": ["@kbn/storybook"],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
|
@ -5,12 +5,5 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { defaultConfig } from '@kbn/storybook';
|
||||
|
||||
module.exports = {
|
||||
...defaultConfig,
|
||||
stories: ['../**/*.stories.+(tsx|mdx)'],
|
||||
reactOptions: {
|
||||
strictMode: true,
|
||||
},
|
||||
};
|
||||
export { KibanaReactStorybookDecorator } from './kibana_react_decorator';
|
||||
export { ReactQueryStorybookDecorator } from './react_query_decorator';
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* 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, { ComponentType } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { createKibanaReactContext, type KibanaServices } from '@kbn/kibana-react-plugin/public';
|
||||
import { UI_SETTINGS } from '@kbn/data-plugin/common';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
const createMockWebStorage = () => ({
|
||||
clear: action('clear'),
|
||||
getItem: action('getItem'),
|
||||
key: action('key'),
|
||||
removeItem: action('removeItem'),
|
||||
setItem: action('setItem'),
|
||||
length: 0,
|
||||
});
|
||||
|
||||
const createMockStorage = () => ({
|
||||
storage: createMockWebStorage(),
|
||||
set: action('set'),
|
||||
remove: action('remove'),
|
||||
clear: action('clear'),
|
||||
get: () => true,
|
||||
});
|
||||
|
||||
const uiSettings: Record<string, unknown> = {
|
||||
[UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS]: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
[UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS]: {
|
||||
pause: true,
|
||||
value: 1000,
|
||||
},
|
||||
[UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [
|
||||
{
|
||||
from: 'now/d',
|
||||
to: 'now/d',
|
||||
display: 'Today',
|
||||
},
|
||||
{
|
||||
from: 'now/w',
|
||||
to: 'now/w',
|
||||
display: 'This week',
|
||||
},
|
||||
{
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
display: 'Last 15 minutes',
|
||||
},
|
||||
{
|
||||
from: 'now-30m',
|
||||
to: 'now',
|
||||
display: 'Last 30 minutes',
|
||||
},
|
||||
{
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
display: 'Last 1 hour',
|
||||
},
|
||||
{
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
display: 'Last 24 hours',
|
||||
},
|
||||
{
|
||||
from: 'now-7d',
|
||||
to: 'now',
|
||||
display: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
from: 'now-30d',
|
||||
to: 'now',
|
||||
display: 'Last 30 days',
|
||||
},
|
||||
{
|
||||
from: 'now-90d',
|
||||
to: 'now',
|
||||
display: 'Last 90 days',
|
||||
},
|
||||
{
|
||||
from: 'now-1y',
|
||||
to: 'now',
|
||||
display: 'Last 1 year',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const services: Partial<KibanaServices> = {
|
||||
uiSettings: {
|
||||
// @ts-ignore
|
||||
get: (key: string) => uiSettings[key],
|
||||
// @ts-ignore
|
||||
get$: (key: string) => of(services.uiSettings.get(key)),
|
||||
},
|
||||
// @ts-ignore
|
||||
settings: { client: { get: () => {} } },
|
||||
notifications: {
|
||||
toasts: {
|
||||
show: action('notifications:show'),
|
||||
success: action('notifications:success'),
|
||||
warning: action('notifications:warning'),
|
||||
danger: action('notifications:danger'),
|
||||
// @ts-ignore
|
||||
addError: action('notifications:addError'),
|
||||
// @ts-ignore
|
||||
addSuccess: action('notifications:addSuccess'),
|
||||
// @ts-ignore
|
||||
addWarning: action('notifications:addWarning'),
|
||||
remove: action('notifications:remove'),
|
||||
},
|
||||
},
|
||||
storage: createMockStorage(),
|
||||
data: {
|
||||
query: {
|
||||
savedQueries: {
|
||||
findSavedQueries: () =>
|
||||
Promise.resolve({
|
||||
queries: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
autocomplete: {
|
||||
hasQuerySuggestions: () => Promise.resolve(false),
|
||||
getQuerySuggestions: () => [],
|
||||
},
|
||||
dataViews: {
|
||||
getIdsWithTitle: () => [],
|
||||
},
|
||||
},
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: action('editDataView'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const KibanaReactStorybookDecorator = (Story: ComponentType) => {
|
||||
const KibanaReactContext = createKibanaReactContext(services);
|
||||
|
||||
return (
|
||||
<KibanaReactContext.Provider>
|
||||
<Story />
|
||||
</KibanaReactContext.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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, { ComponentType } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
export const ReactQueryStorybookDecorator = (Story: ComponentType) => {
|
||||
const mockQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, cacheTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { defaultConfig, mergeWebpackFinal } from '@kbn/storybook';
|
||||
import type { Configuration } from 'webpack';
|
||||
// eslint-disable-next-line import/no-nodejs-modules
|
||||
import { resolve } from 'path';
|
||||
|
||||
const graphWebpack: Configuration = {
|
||||
resolve: {
|
||||
alias: {
|
||||
'../../hooks/use_fetch_graph_data': resolve(
|
||||
__dirname,
|
||||
'../src/components/mock/use_fetch_graph_data.mock.ts'
|
||||
),
|
||||
},
|
||||
},
|
||||
node: {
|
||||
fs: 'empty',
|
||||
stream: false,
|
||||
os: false,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
...defaultConfig,
|
||||
stories: ['../**/*.stories.+(tsx|mdx)'],
|
||||
reactOptions: {
|
||||
strictMode: true,
|
||||
},
|
||||
...mergeWebpackFinal(graphWebpack),
|
||||
};
|
|
@ -113,6 +113,6 @@ Be sure to check out provided helpers
|
|||
## Storybook
|
||||
|
||||
General look of the component can be checked visually running the following storybook:
|
||||
`yarn storybook cloud_security_posture_packages`
|
||||
`yarn storybook cloud_security_posture_graph`
|
||||
|
||||
Note that all the interactions are mocked.
|
|
@ -11,7 +11,7 @@ module.exports = {
|
|||
rootDir: '../../../../../..',
|
||||
transform: {
|
||||
'^.+\\.(js|tsx?)$':
|
||||
'<rootDir>/x-pack/solutions/security/packages/kbn-cloud-security-posture/.storybook/babel_with_emotion.ts',
|
||||
'<rootDir>/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/.storybook/babel_with_emotion.ts',
|
||||
},
|
||||
setupFiles: ['jest-canvas-mock'],
|
||||
setupFilesAfterEnv: [
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { ThemeProvider, css } from '@emotion/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Actions as ActionsComponent, type ActionsProps } from './actions';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Additional Components',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: Story<ActionsProps> = (props) => {
|
||||
return (
|
||||
<ThemeProvider theme={{ darkMode: false }}>
|
||||
<ActionsComponent
|
||||
css={css`
|
||||
width: 42px;
|
||||
`}
|
||||
onInvestigateInTimeline={action('investigateInTimeline')}
|
||||
onSearchToggle={action('searchToggle')}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Actions = Template.bind({});
|
||||
|
||||
Actions.args = {
|
||||
showToggleSearch: true,
|
||||
searchFilterCounter: 0,
|
||||
showInvestigateInTimeline: true,
|
||||
};
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { Actions, ActionsProps } from './actions';
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
import {
|
||||
GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID,
|
||||
GRAPH_ACTIONS_TOGGLE_SEARCH_ID,
|
||||
} from '../test_ids';
|
||||
|
||||
const defaultProps: ActionsProps = {
|
||||
showToggleSearch: true,
|
||||
showInvestigateInTimeline: true,
|
||||
onSearchToggle: jest.fn(),
|
||||
onInvestigateInTimeline: jest.fn(),
|
||||
searchFilterCounter: 0,
|
||||
};
|
||||
|
||||
const renderWithProviders = (props: ActionsProps = defaultProps) => {
|
||||
return render(
|
||||
<EuiThemeProvider>
|
||||
<Actions {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Actions component', () => {
|
||||
it('renders toggle search button', () => {
|
||||
const { getByTestId, getByLabelText } = renderWithProviders();
|
||||
|
||||
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toBeInTheDocument();
|
||||
expect(getByLabelText('Toggle search bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders investigate in timeline button', () => {
|
||||
const { getByTestId, getByLabelText } = renderWithProviders();
|
||||
|
||||
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toBeInTheDocument();
|
||||
expect(getByLabelText('Investigate in timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSearchToggle when toggle search button is clicked', () => {
|
||||
const { getByTestId } = renderWithProviders();
|
||||
|
||||
fireEvent.click(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID));
|
||||
|
||||
expect(defaultProps.onSearchToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onInvestigateInTimeline when investigate in timeline button is clicked', () => {
|
||||
const { getByTestId } = renderWithProviders();
|
||||
|
||||
fireEvent.click(getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID));
|
||||
|
||||
expect(defaultProps.onInvestigateInTimeline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render toggle search button when showToggleSearch is false', () => {
|
||||
const { queryByTestId, queryByLabelText } = renderWithProviders({
|
||||
...defaultProps,
|
||||
showToggleSearch: false,
|
||||
});
|
||||
expect(queryByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('Toggle search bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render investigate in timeline button when showInvestigateInTimeline is false', () => {
|
||||
const { queryByTestId, queryByLabelText } = renderWithProviders({
|
||||
...defaultProps,
|
||||
showInvestigateInTimeline: false,
|
||||
});
|
||||
expect(queryByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID)).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('Investigate in timeline')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search filter counter badge when searchFilterCounter is equal to 0', () => {
|
||||
const { queryByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 0 });
|
||||
expect(queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search filter counter badge when searchFilterCounter is greater than 0', () => {
|
||||
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 5 });
|
||||
expect(getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "9" in search filter counter badge when searchFilterCounter is equal to 9', () => {
|
||||
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 9 });
|
||||
expect(getByText('9')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "9+" in search filter counter badge when searchFilterCounter is greater than 9', () => {
|
||||
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 10 });
|
||||
expect(getByText('9+')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* 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, { useState } from 'react';
|
||||
import {
|
||||
type CommonProps,
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiToolTip,
|
||||
useEuiTheme,
|
||||
EuiNotificationBadge,
|
||||
EuiButton,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import {
|
||||
GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID,
|
||||
GRAPH_ACTIONS_TOGGLE_SEARCH_ID,
|
||||
} from '../test_ids';
|
||||
|
||||
const toggleSearchBarTooltip = i18n.translate(
|
||||
'securitySolutionPackages.csp.graph.controls.toggleSearchBar',
|
||||
{
|
||||
defaultMessage: 'Toggle search bar',
|
||||
}
|
||||
);
|
||||
|
||||
const investigateInTimelineTooltip = i18n.translate(
|
||||
'securitySolutionPackages.csp.graph.controls.investigate',
|
||||
{
|
||||
defaultMessage: 'Investigate in timeline',
|
||||
}
|
||||
);
|
||||
|
||||
export interface ActionsProps extends CommonProps {
|
||||
/**
|
||||
* Whether to show toggle search action button. Defaults value is false.
|
||||
*/
|
||||
showToggleSearch?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when search toggle button is clicked.
|
||||
*/
|
||||
onSearchToggle?: (isSearchToggled: boolean) => void;
|
||||
|
||||
/**
|
||||
* Number of search filters applied, used to show badge on search toggle button.
|
||||
*/
|
||||
searchFilterCounter?: number;
|
||||
|
||||
/**
|
||||
* Whether to show investigate in timeline action button. Defaults value is false.
|
||||
*/
|
||||
showInvestigateInTimeline?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when investigate in timeline action button is clicked, ignored if investigateInTimelineComponent is provided.
|
||||
*/
|
||||
onInvestigateInTimeline?: () => void;
|
||||
}
|
||||
|
||||
export const Actions = ({
|
||||
showToggleSearch = true,
|
||||
showInvestigateInTimeline = true,
|
||||
onInvestigateInTimeline,
|
||||
onSearchToggle,
|
||||
searchFilterCounter = 0,
|
||||
...props
|
||||
}: ActionsProps) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [searchToggled, setSearchToggled] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize={'none'} {...props}>
|
||||
{showToggleSearch && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip content={toggleSearchBarTooltip} position="left">
|
||||
<EuiButton
|
||||
iconType="search"
|
||||
color={searchToggled ? 'primary' : 'text'}
|
||||
fill={searchToggled}
|
||||
css={[
|
||||
css`
|
||||
position: relative;
|
||||
width: 40px;
|
||||
`,
|
||||
!searchToggled
|
||||
? css`
|
||||
border: ${euiTheme.border.thin};
|
||||
background-color: ${euiTheme.colors.backgroundBasePlain};
|
||||
`
|
||||
: undefined,
|
||||
]}
|
||||
minWidth={false}
|
||||
size="m"
|
||||
aria-label={toggleSearchBarTooltip}
|
||||
data-test-subj={GRAPH_ACTIONS_TOGGLE_SEARCH_ID}
|
||||
onClick={() => {
|
||||
setSearchToggled((prev) => {
|
||||
onSearchToggle?.(!prev);
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{searchFilterCounter > 0 && (
|
||||
<EuiNotificationBadge
|
||||
css={css`
|
||||
position: absolute;
|
||||
right: ${-4.5 + (searchToggled ? 1 : 0)}px;
|
||||
bottom: ${-4.5 + (searchToggled ? 1 : 0)}px;
|
||||
transition: all ${euiTheme.animation.fast} ease-in, right 0s linear,
|
||||
bottom 0s linear !important;
|
||||
`}
|
||||
>
|
||||
{searchFilterCounter > 9 ? '9+' : searchFilterCounter}
|
||||
</EuiNotificationBadge>
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{showToggleSearch && showInvestigateInTimeline && <EuiHorizontalRule margin="xs" />}
|
||||
{showInvestigateInTimeline && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip content={investigateInTimelineTooltip} position="left">
|
||||
<EuiButtonIcon
|
||||
iconType="timeline"
|
||||
display="base"
|
||||
size="m"
|
||||
aria-label={investigateInTimelineTooltip}
|
||||
data-test-subj={GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID}
|
||||
onClick={() => {
|
||||
onInvestigateInTimeline?.();
|
||||
}}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -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 from 'react';
|
||||
import { Story, type Meta } from '@storybook/react';
|
||||
import { ThemeProvider, css } from '@emotion/react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Controls as ControlsComponent, type ControlsProps } from './controls';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Additional Components',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {
|
||||
showZoom: {
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
showFitView: {
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
showCenter: {
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<ControlsProps> = (props) => {
|
||||
return (
|
||||
<ThemeProvider theme={{ darkMode: false }}>
|
||||
<ReactFlowProvider>
|
||||
<ControlsComponent
|
||||
css={css`
|
||||
width: 42px;
|
||||
`}
|
||||
onZoomIn={action('zoomIn')}
|
||||
onZoomOut={action('zoomOut')}
|
||||
onFitView={action('fitView')}
|
||||
onCenter={action('center')}
|
||||
{...props}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Controls = Template.bind({});
|
||||
|
||||
Controls.args = {
|
||||
showZoom: true,
|
||||
showFitView: true,
|
||||
showCenter: true,
|
||||
};
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { Controls, ControlsProps } from './controls';
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
import { useStore, useReactFlow } from '@xyflow/react';
|
||||
|
||||
const defaultProps: ControlsProps = {
|
||||
showZoom: true,
|
||||
showFitView: true,
|
||||
showCenter: true,
|
||||
};
|
||||
|
||||
jest.mock('@xyflow/react', () => ({
|
||||
useStore: jest.fn(),
|
||||
useReactFlow: jest.fn(),
|
||||
}));
|
||||
|
||||
const useReactFlowMock = useReactFlow as jest.Mock;
|
||||
const useStoreMock = useStore as jest.Mock;
|
||||
|
||||
const renderWithProviders = (props: ControlsProps = defaultProps) => {
|
||||
return render(
|
||||
<EuiThemeProvider>
|
||||
<Controls {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Controls', () => {
|
||||
beforeEach(() => {
|
||||
useReactFlowMock.mockReturnValue({
|
||||
zoomIn: jest.fn(),
|
||||
zoomOut: jest.fn(),
|
||||
fitView: jest.fn(),
|
||||
});
|
||||
|
||||
useStoreMock.mockReturnValue({ minZoomReached: false, maxZoomReached: false });
|
||||
});
|
||||
|
||||
it('renders zoom in and zoom out buttons', () => {
|
||||
const { getByLabelText } = renderWithProviders();
|
||||
expect(getByLabelText('Zoom in')).toBeInTheDocument();
|
||||
expect(getByLabelText('Zoom out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders fit view button', () => {
|
||||
const { getByLabelText } = renderWithProviders();
|
||||
expect(getByLabelText('Fit view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders center button', () => {
|
||||
const { getByLabelText } = renderWithProviders();
|
||||
expect(getByLabelText('Center')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onZoomIn when zoom in button is clicked', () => {
|
||||
const onZoomIn = jest.fn();
|
||||
const { getByLabelText } = renderWithProviders({ ...defaultProps, onZoomIn });
|
||||
|
||||
fireEvent.click(getByLabelText('Zoom in'));
|
||||
|
||||
expect(useReactFlowMock().zoomIn).toHaveBeenCalled();
|
||||
expect(onZoomIn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onZoomOut when zoom out button is clicked', () => {
|
||||
const onZoomOut = jest.fn();
|
||||
const { getByLabelText } = renderWithProviders({ ...defaultProps, onZoomOut });
|
||||
|
||||
fireEvent.click(getByLabelText('Zoom out'));
|
||||
|
||||
expect(useReactFlowMock().zoomOut).toHaveBeenCalled();
|
||||
expect(onZoomOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFitView when fit view button is clicked', () => {
|
||||
const onFitView = jest.fn();
|
||||
const { getByLabelText } = renderWithProviders({ ...defaultProps, onFitView });
|
||||
|
||||
fireEvent.click(getByLabelText('Fit view'));
|
||||
|
||||
expect(useReactFlowMock().fitView).toHaveBeenCalled();
|
||||
expect(onFitView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCenter when center button is clicked', () => {
|
||||
const onCenter = jest.fn();
|
||||
const { getByLabelText } = renderWithProviders({ ...defaultProps, onCenter });
|
||||
|
||||
fireEvent.click(getByLabelText('Center'));
|
||||
|
||||
expect(onCenter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables zoom in button when max zoom is reached', () => {
|
||||
useStoreMock.mockReturnValue({ minZoomReached: false, maxZoomReached: true });
|
||||
|
||||
const { getByLabelText } = renderWithProviders();
|
||||
|
||||
const zoomInButton = getByLabelText('Zoom in');
|
||||
expect(zoomInButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables zoom out button when min zoom is reached', () => {
|
||||
useStoreMock.mockReturnValue({ minZoomReached: true, maxZoomReached: false });
|
||||
|
||||
const { getByLabelText } = renderWithProviders();
|
||||
|
||||
const zoomOutButton = getByLabelText('Zoom out');
|
||||
expect(zoomOutButton).toBeDisabled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
useEuiTheme,
|
||||
type CommonProps,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { type ReactFlowState, useStore, useReactFlow, type FitViewOptions } from '@xyflow/react';
|
||||
import {
|
||||
GRAPH_CONTROLS_FIT_VIEW_ID,
|
||||
GRAPH_CONTROLS_CENTER_ID,
|
||||
GRAPH_CONTROLS_ZOOM_IN_ID,
|
||||
GRAPH_CONTROLS_ZOOM_OUT_ID,
|
||||
} from '../test_ids';
|
||||
|
||||
const selector = (s: ReactFlowState) => ({
|
||||
minZoomReached: s.transform[2] <= s.minZoom,
|
||||
maxZoomReached: s.transform[2] >= s.maxZoom,
|
||||
});
|
||||
|
||||
export interface ControlsProps extends CommonProps {
|
||||
showZoom?: boolean;
|
||||
showFitView?: boolean;
|
||||
showCenter?: boolean;
|
||||
fitViewOptions?: FitViewOptions;
|
||||
/** Callback when zoom in button is clicked */
|
||||
onZoomIn?: () => void;
|
||||
/** Callback when zoom out button is clicked */
|
||||
onZoomOut?: () => void;
|
||||
/** Callback when fit view button is clicked */
|
||||
onFitView?: () => void;
|
||||
/** Callback when center button is clicked */
|
||||
onCenter?: () => void;
|
||||
}
|
||||
|
||||
const ZoomInLabel = i18n.translate('securitySolutionPackages.csp.graph.controls.zoomIn', {
|
||||
defaultMessage: 'Zoom in',
|
||||
});
|
||||
const ZoomOutLabel = i18n.translate('securitySolutionPackages.csp.graph.controls.zoomOut', {
|
||||
defaultMessage: 'Zoom out',
|
||||
});
|
||||
const FitViewLabel = i18n.translate('securitySolutionPackages.csp.graph.controls.fitView', {
|
||||
defaultMessage: 'Fit view',
|
||||
});
|
||||
const CenterLabel = i18n.translate('securitySolutionPackages.csp.graph.controls.center', {
|
||||
defaultMessage: 'Center',
|
||||
});
|
||||
|
||||
export const Controls = ({
|
||||
showZoom = true,
|
||||
showFitView = true,
|
||||
showCenter = true,
|
||||
fitViewOptions,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onCenter,
|
||||
onFitView,
|
||||
...props
|
||||
}: ControlsProps) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const { maxZoomReached, minZoomReached } = useStore(selector);
|
||||
|
||||
const onZoomInHandler = () => {
|
||||
zoomIn();
|
||||
onZoomIn?.();
|
||||
};
|
||||
|
||||
const onZoomOutHandler = () => {
|
||||
zoomOut();
|
||||
onZoomOut?.();
|
||||
};
|
||||
|
||||
const onFitViewHandler = () => {
|
||||
fitView(fitViewOptions);
|
||||
onFitView?.();
|
||||
};
|
||||
|
||||
const btnCss = css`
|
||||
border: ${euiTheme.border.thin};
|
||||
border-radius: ${euiTheme.border.radius.medium};
|
||||
background-color: ${euiTheme.colors.backgroundBasePlain};
|
||||
box-sizing: content-box;
|
||||
`;
|
||||
|
||||
if (!showZoom && !showCenter && !showFitView) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize={'none'} {...props}>
|
||||
{showZoom && (
|
||||
<EuiFlexItem grow={false} css={btnCss}>
|
||||
<EuiButtonIcon
|
||||
iconType="plusInCircle"
|
||||
aria-label={ZoomInLabel}
|
||||
size="m"
|
||||
color="text"
|
||||
data-test-subj={GRAPH_CONTROLS_ZOOM_IN_ID}
|
||||
disabled={maxZoomReached}
|
||||
onClick={onZoomInHandler}
|
||||
/>
|
||||
<EuiButtonIcon
|
||||
iconType="minusInCircle"
|
||||
aria-label={ZoomOutLabel}
|
||||
size="m"
|
||||
color="text"
|
||||
data-test-subj={GRAPH_CONTROLS_ZOOM_OUT_ID}
|
||||
disabled={minZoomReached}
|
||||
onClick={onZoomOutHandler}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{showZoom && showCenter && <EuiSpacer size="xs" />}
|
||||
{showCenter && (
|
||||
<EuiButtonIcon
|
||||
iconType="bullseye"
|
||||
aria-label={CenterLabel}
|
||||
size="m"
|
||||
color="text"
|
||||
data-test-subj={GRAPH_CONTROLS_CENTER_ID}
|
||||
css={btnCss}
|
||||
onClick={() => onCenter?.()}
|
||||
/>
|
||||
)}
|
||||
{(showZoom || showCenter) && showFitView && <EuiSpacer size="xs" />}
|
||||
{showFitView && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="continuityWithin"
|
||||
aria-label={FitViewLabel}
|
||||
size="m"
|
||||
color="text"
|
||||
data-test-subj={GRAPH_CONTROLS_FIT_VIEW_ID}
|
||||
css={btnCss}
|
||||
onClick={onFitViewHandler}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -31,7 +31,7 @@ import '@xyflow/react/dist/style.css';
|
|||
import { HandleStyleOverride } from '../node/styles';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Default Edge',
|
||||
title: 'Components/Graph Components',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {
|
||||
color: {
|
||||
|
|
|
@ -9,7 +9,7 @@ import React, { useState, useCallback, useEffect, useRef, memo } from 'react';
|
|||
import { size, isEmpty, isEqual, xorWith } from 'lodash';
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
|
@ -34,6 +34,7 @@ import type { EdgeViewModel, NodeViewModel } from '../types';
|
|||
import { ONLY_RENDER_VISIBLE_ELEMENTS } from './constants';
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Controls } from '../controls/controls';
|
||||
|
||||
export interface GraphProps extends CommonProps {
|
||||
/**
|
||||
|
@ -53,6 +54,10 @@ export interface GraphProps extends CommonProps {
|
|||
* Determines whether the graph is locked. Nodes and edges are still interactive, but the graph itself is not.
|
||||
*/
|
||||
isLocked?: boolean;
|
||||
/**
|
||||
* Additional children to be rendered inside the graph component.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
|
@ -84,14 +89,14 @@ const edgeTypes = {
|
|||
* @returns {JSX.Element} The rendered Graph component.
|
||||
*/
|
||||
export const Graph = memo<GraphProps>(
|
||||
({ nodes, edges, interactive, isLocked = false, ...rest }: GraphProps) => {
|
||||
({ nodes, edges, interactive, isLocked = false, children, ...rest }: GraphProps) => {
|
||||
const backgroundId = useGeneratedHtmlId();
|
||||
const fitViewRef = useRef<
|
||||
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
|
||||
>(null);
|
||||
const currNodesRef = useRef<NodeViewModel[]>([]);
|
||||
const currEdgesRef = useRef<EdgeViewModel[]>([]);
|
||||
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
|
||||
const [isGraphInteractive, _setIsGraphInteractive] = useState(interactive);
|
||||
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
|
||||
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
|
||||
|
||||
|
@ -114,22 +119,6 @@ export const Graph = memo<GraphProps>(
|
|||
}
|
||||
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
|
||||
|
||||
const onInteractiveStateChange = useCallback(
|
||||
(interactiveStatus: boolean): void => {
|
||||
setIsGraphInteractive(interactiveStatus);
|
||||
setNodes((currNodes) =>
|
||||
currNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
interactive: interactiveStatus,
|
||||
},
|
||||
}))
|
||||
);
|
||||
},
|
||||
[setNodes]
|
||||
);
|
||||
|
||||
const onInitCallback = useCallback(
|
||||
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
|
||||
window.requestAnimationFrame(() => xyflow.fitView());
|
||||
|
@ -174,7 +163,12 @@ export const Graph = memo<GraphProps>(
|
|||
maxZoom={1.3}
|
||||
minZoom={0.1}
|
||||
>
|
||||
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
|
||||
{interactive && (
|
||||
<Panel position="bottom-right">
|
||||
<Controls showCenter={false} />
|
||||
</Panel>
|
||||
)}
|
||||
{children}
|
||||
<Background id={backgroundId} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
|
|
@ -5,11 +5,23 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { FpsTrendline } from './fps_trendline';
|
||||
|
||||
export const GraphPerfMonitor: React.FC = () => {
|
||||
const [nodeCount, setNodeCount] = useState(0);
|
||||
const [edgeCount, setEdgeCount] = useState(0);
|
||||
const updateCounts = () => {
|
||||
setNodeCount(document.getElementsByClassName('react-flow__node').length);
|
||||
setEdgeCount(document.getElementsByClassName('react-flow__edge').length);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(updateCounts, 300);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
|
@ -17,8 +29,7 @@ export const GraphPerfMonitor: React.FC = () => {
|
|||
position: 'fixed',
|
||||
}}
|
||||
>
|
||||
<strong>{'Nodes:'}</strong> {document.getElementsByClassName('react-flow__node').length}{' '}
|
||||
<strong>{'Edges:'}</strong> {document.getElementsByClassName('react-flow__edge').length}
|
||||
<strong>{'Nodes:'}</strong> {nodeCount} <strong>{'Edges:'}</strong> {edgeCount}
|
||||
<FpsTrendline
|
||||
css={css`
|
||||
width: 300px;
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { setProjectAnnotations } from '@storybook/react';
|
||||
import { composeStories } from '@storybook/testing-react';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import * as stories from './graph_investigation.stories';
|
||||
import { type GraphInvestigationProps } from './graph_investigation';
|
||||
import { GRAPH_INVESTIGATION_TEST_ID, GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID } from '../test_ids';
|
||||
import * as previewAnnotations from '../../../.storybook/preview';
|
||||
|
||||
setProjectAnnotations(previewAnnotations);
|
||||
|
||||
const { Investigation } = composeStories(stories);
|
||||
|
||||
jest.mock('../../hooks/use_fetch_graph_data', () => {
|
||||
return require('../mock/use_fetch_graph_data.mock');
|
||||
});
|
||||
|
||||
const renderStory = (args: Partial<GraphInvestigationProps> = {}) => {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<Investigation {...args} />
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Turn off the optimization that hides elements that are not visible in the viewport
|
||||
jest.mock('../graph/constants', () => ({
|
||||
ONLY_RENDER_VISIBLE_ELEMENTS: false,
|
||||
}));
|
||||
|
||||
describe('GraphInvestigation Component', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { getByTestId } = renderStory();
|
||||
|
||||
expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with initial state', () => {
|
||||
const { container, getAllByText } = renderStory();
|
||||
|
||||
const nodes = container.querySelectorAll('.react-flow__nodes .react-flow__node');
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(getAllByText('~ an hour ago')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls onInvestigateInTimeline action', () => {
|
||||
const onInvestigateInTimeline = jest.fn();
|
||||
const { getByTestId } = renderStory({
|
||||
onInvestigateInTimeline,
|
||||
showInvestigateInTimeline: true,
|
||||
});
|
||||
|
||||
getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID).click();
|
||||
|
||||
expect(onInvestigateInTimeline).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { GraphInvestigation, type GraphInvestigationProps } from './graph_investigation';
|
||||
import {
|
||||
KibanaReactStorybookDecorator,
|
||||
ReactQueryStorybookDecorator,
|
||||
} from '../../../.storybook/decorators';
|
||||
import { mockDataView } from '../mock/data_view.mock';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Investigation',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {
|
||||
showToggleSearch: {
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
showInvestigateInTimeline: {
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
decorators: [ReactQueryStorybookDecorator, KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const hourAgo = new Date(new Date().getTime() - 60 * 60 * 1000);
|
||||
const defaultProps: GraphInvestigationProps = {
|
||||
initialState: {
|
||||
dataView: mockDataView,
|
||||
originEventIds: [
|
||||
{
|
||||
id: '1',
|
||||
isAlert: false,
|
||||
},
|
||||
],
|
||||
timeRange: {
|
||||
from: `${hourAgo.toISOString()}||-15m`,
|
||||
to: `${hourAgo.toISOString()}||+15m`,
|
||||
},
|
||||
},
|
||||
onInvestigateInTimeline: action('onInvestigateInTimeline'),
|
||||
showToggleSearch: false,
|
||||
showInvestigateInTimeline: false,
|
||||
};
|
||||
|
||||
const Template: Story<Partial<GraphInvestigationProps>> = (props) => {
|
||||
return <GraphInvestigation {...defaultProps} {...props} />;
|
||||
};
|
||||
|
||||
export const Investigation = Template.bind({});
|
||||
|
||||
Investigation.args = {
|
||||
showToggleSearch: false,
|
||||
showInvestigateInTimeline: false,
|
||||
};
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@kbn/es-query';
|
||||
import type { Filter, Query, TimeRange, PhraseFilter } from '@kbn/es-query';
|
||||
import { css } from '@emotion/react';
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { getEsQueryConfig } from '@kbn/data-service';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { Graph, isEntityNode } from '../../..';
|
||||
|
@ -32,6 +33,7 @@ import {
|
|||
RELATED_ENTITY,
|
||||
TARGET_ENTITY_ID,
|
||||
} from '../../common/constants';
|
||||
import { Actions, type ActionsProps } from '../controls/actions';
|
||||
|
||||
const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
|
||||
|
||||
|
@ -137,7 +139,7 @@ const useGraphPopovers = (
|
|||
return { nodeExpandPopover, labelExpandPopover, openPopoverCallback };
|
||||
};
|
||||
|
||||
interface GraphInvestigationProps {
|
||||
export interface GraphInvestigationProps {
|
||||
/**
|
||||
* The initial state to use for the graph investigation view.
|
||||
*/
|
||||
|
@ -167,6 +169,21 @@ interface GraphInvestigationProps {
|
|||
*/
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to show investigate in timeline action button. Defaults value is false.
|
||||
*/
|
||||
showInvestigateInTimeline?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when investigate in timeline action button is clicked, ignored if investigateInTimelineComponent is provided.
|
||||
*/
|
||||
onInvestigateInTimeline?: (filters: Filter[], timeRange: TimeRange) => void;
|
||||
|
||||
/**
|
||||
* Whether to show toggle search action button. Defaults value is false.
|
||||
*/
|
||||
showToggleSearch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -175,10 +192,29 @@ interface GraphInvestigationProps {
|
|||
export const GraphInvestigation = memo<GraphInvestigationProps>(
|
||||
({
|
||||
initialState: { dataView, originEventIds, timeRange: initialTimeRange },
|
||||
showInvestigateInTimeline = false,
|
||||
showToggleSearch = false,
|
||||
onInvestigateInTimeline,
|
||||
}: GraphInvestigationProps) => {
|
||||
const [searchFilters, setSearchFilters] = useState<Filter[]>(() => []);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
|
||||
|
||||
const onInvestigateInTimelineCallback = useCallback(() => {
|
||||
const filters = originEventIds.reduce<Filter[]>((acc, { id }) => {
|
||||
return addFilter(dataView?.id ?? '', acc, 'event.id', id);
|
||||
}, searchFilters);
|
||||
onInvestigateInTimeline?.(filters, timeRange);
|
||||
}, [dataView?.id, onInvestigateInTimeline, originEventIds, searchFilters, timeRange]);
|
||||
|
||||
const actionsProps: ActionsProps = useMemo(
|
||||
() => ({
|
||||
showInvestigateInTimeline,
|
||||
showToggleSearch,
|
||||
onInvestigateInTimeline: onInvestigateInTimelineCallback,
|
||||
}),
|
||||
[onInvestigateInTimelineCallback, showInvestigateInTimeline, showToggleSearch]
|
||||
);
|
||||
|
||||
const {
|
||||
services: { uiSettings },
|
||||
} = useKibana();
|
||||
|
@ -293,7 +329,11 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
|
|||
edges={data?.edges ?? []}
|
||||
interactive={true}
|
||||
isLocked={isPopoverOpen}
|
||||
/>
|
||||
>
|
||||
<Panel position="top-right">
|
||||
<Actions {...actionsProps} />
|
||||
</Panel>
|
||||
</Graph>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<nodeExpandPopover.PopoverComponent />
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 type { DataView } from '@kbn/data-views-plugin/common';
|
||||
|
||||
export const mockDataView = {
|
||||
id: '1235',
|
||||
title: 'test-*',
|
||||
fields: [
|
||||
{
|
||||
name: 'actor.entity.id',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'target.entity.id',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'related.entity',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
name: 'event.action',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
getName: () => 'test-*',
|
||||
} as DataView;
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
export const useFetchGraphData = () =>
|
||||
useMemo(
|
||||
() => ({
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'admin@example.com',
|
||||
label: 'admin@example.com',
|
||||
color: 'primary',
|
||||
shape: 'ellipse',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
id: 'projects/your-project-id/roles/customRole',
|
||||
label: 'projects/your-project-id/roles/customRole',
|
||||
color: 'primary',
|
||||
shape: 'hexagon',
|
||||
icon: 'questionInCircle',
|
||||
},
|
||||
{
|
||||
id: 'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
|
||||
label: 'google.iam.admin.v1.CreateRole',
|
||||
source: 'admin@example.com',
|
||||
target: 'projects/your-project-id/roles/customRole',
|
||||
color: 'primary',
|
||||
shape: 'label',
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'a(admin@example.com)-b(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))',
|
||||
source: 'admin@example.com',
|
||||
sourceShape: 'ellipse',
|
||||
target:
|
||||
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
|
||||
targetShape: 'label',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'a(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))-b(projects/your-project-id/roles/customRole)',
|
||||
source:
|
||||
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
|
||||
sourceShape: 'label',
|
||||
target: 'projects/your-project-id/roles/customRole',
|
||||
targetShape: 'hexagon',
|
||||
color: 'primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
refresh: action('refresh'),
|
||||
}),
|
||||
[]
|
||||
);
|
|
@ -16,7 +16,7 @@ import { HexagonNode, PentagonNode, EllipseNode, RectangleNode, DiamondNode, Lab
|
|||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Labels',
|
||||
title: 'Components/Graph Components/Additional Components/Labels',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {
|
||||
color: {
|
||||
|
|
|
@ -10,24 +10,29 @@
|
|||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { type NodeButtonProps, NodeShapeContainer } from './styles';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
import { NodeShapeContainer } from './styles';
|
||||
import { NodeExpandButton, type NodeExpandButtonProps } from './node_expand_button';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components',
|
||||
title: 'Components/Graph Components/Additional Components',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {
|
||||
onClick: { action: 'onClick' },
|
||||
color: {
|
||||
options: ['primary', 'danger', 'warning'],
|
||||
control: { type: 'radio' },
|
||||
defaultValue: 'primary',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template: Story<NodeButtonProps> = (args) => (
|
||||
const Template: Story<NodeExpandButtonProps> = (args) => (
|
||||
<ThemeProvider theme={{ darkMode: false }}>
|
||||
<NodeShapeContainer>
|
||||
Hover me
|
||||
<NodeExpandButton onClick={args.onClick} />
|
||||
<NodeExpandButton color={args.color} onClick={args.onClick} />
|
||||
</NodeShapeContainer>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
export const Button = Template.bind({});
|
||||
export const ExpandButton = Template.bind({});
|
|
@ -21,3 +21,12 @@ export const GRAPH_LABEL_EXPAND_POPOVER_TEST_ID =
|
|||
`${GRAPH_INVESTIGATION_TEST_ID}GraphLabelExpandPopover` as const;
|
||||
export const GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}ShowEventsWithThisAction` as const;
|
||||
|
||||
export const GRAPH_ACTIONS_TOGGLE_SEARCH_ID = `${GRAPH_INVESTIGATION_TEST_ID}ToggleSearch` as const;
|
||||
export const GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID =
|
||||
`${GRAPH_INVESTIGATION_TEST_ID}InvestigateInTimeline` as const;
|
||||
|
||||
export const GRAPH_CONTROLS_ZOOM_IN_ID = `${GRAPH_INVESTIGATION_TEST_ID}ZoomIn` as const;
|
||||
export const GRAPH_CONTROLS_ZOOM_OUT_ID = `${GRAPH_INVESTIGATION_TEST_ID}ZoomOut` as const;
|
||||
export const GRAPH_CONTROLS_CENTER_ID = `${GRAPH_INVESTIGATION_TEST_ID}Center` as const;
|
||||
export const GRAPH_CONTROLS_FIT_VIEW_ID = `${GRAPH_INVESTIGATION_TEST_ID}FitView` as const;
|
||||
|
|
|
@ -3,14 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/mock/*.json",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/mock/*.json", ".storybook/**/*.ts", ".storybook/**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/cloud-security-posture-common",
|
||||
"@kbn/data-views-plugin",
|
||||
|
@ -21,5 +15,8 @@
|
|||
"@kbn/es-query",
|
||||
"@kbn/data-service",
|
||||
"@kbn/i18n",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/storybook",
|
||||
"@kbn/data-plugin"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -10,26 +10,28 @@ import React, { useCallback } from 'react';
|
|||
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
|
||||
import type { IconType, EuiButtonEmptyProps } from '@elastic/eui';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sourcererSelectors } from '../../store';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import type { TimeRange } from '../../store/inputs/model';
|
||||
import { inputsActions } from '../../store/inputs';
|
||||
import { updateProviders, setFilters } from '../../../timelines/store/actions';
|
||||
import { sourcererActions } from '../../store/actions';
|
||||
import { SourcererScopeName } from '../../../sourcerer/store/model';
|
||||
import type { DataProvider } from '../../../../common/types';
|
||||
import { TimelineId } from '../../../../common/types/timeline';
|
||||
import { TimelineTypeEnum } from '../../../../common/api/timeline';
|
||||
import { useCreateTimeline } from '../../../timelines/hooks/use_create_timeline';
|
||||
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../detections/components/alerts_table/translations';
|
||||
import { useInvestigateInTimeline } from '../../hooks/timeline/use_investigate_in_timeline';
|
||||
|
||||
export interface InvestigateInTimelineButtonProps {
|
||||
asEmptyButton: boolean;
|
||||
/**
|
||||
* The data providers to apply to the timeline.
|
||||
*/
|
||||
dataProviders: DataProvider[] | null;
|
||||
/**
|
||||
* The filters to apply to the timeline.
|
||||
*/
|
||||
filters?: Filter[] | null;
|
||||
/**
|
||||
* The time range to apply to the timeline, defaults to global time range.
|
||||
*/
|
||||
timeRange?: TimeRange;
|
||||
/**
|
||||
* Whether to keep the current data view or reset it to the default.
|
||||
*/
|
||||
keepDataView?: boolean;
|
||||
isDisabled?: boolean;
|
||||
iconType?: IconType;
|
||||
|
@ -37,6 +39,10 @@ export interface InvestigateInTimelineButtonProps {
|
|||
flush?: EuiButtonEmptyProps['flush'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a EuiEmptyButton or a normal EuiButton to wrap some content and attaches a
|
||||
* investigate in timeline callback to the click event.
|
||||
*/
|
||||
export const InvestigateInTimelineButton: FC<
|
||||
PropsWithChildren<InvestigateInTimelineButtonProps>
|
||||
> = ({
|
||||
|
@ -50,76 +56,20 @@ export const InvestigateInTimelineButton: FC<
|
|||
flush,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
|
||||
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
|
||||
|
||||
const hasTemplateProviders =
|
||||
dataProviders && dataProviders.find((provider) => provider.type === 'template');
|
||||
|
||||
const clearTimeline = useCreateTimeline({
|
||||
timelineId: TimelineId.active,
|
||||
timelineType: hasTemplateProviders ? TimelineTypeEnum.template : TimelineTypeEnum.default,
|
||||
});
|
||||
|
||||
const configureAndOpenTimeline = useCallback(async () => {
|
||||
if (dataProviders || filters) {
|
||||
// Reset the current timeline
|
||||
if (timeRange) {
|
||||
await clearTimeline({
|
||||
timeRange,
|
||||
});
|
||||
} else {
|
||||
await clearTimeline();
|
||||
}
|
||||
if (dataProviders) {
|
||||
// Update the timeline's providers to match the current prevalence field query
|
||||
dispatch(
|
||||
updateProviders({
|
||||
id: TimelineId.active,
|
||||
providers: dataProviders,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Use filters if more than a certain amount of ids for dom performance.
|
||||
if (filters) {
|
||||
dispatch(
|
||||
setFilters({
|
||||
id: TimelineId.active,
|
||||
filters,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Only show detection alerts
|
||||
// (This is required so the timeline event count matches the prevalence count)
|
||||
if (!keepDataView) {
|
||||
dispatch(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: defaultDataView.id,
|
||||
selectedPatterns: [signalIndexName || ''],
|
||||
})
|
||||
);
|
||||
}
|
||||
// Unlock the time range from the global time range
|
||||
dispatch(inputsActions.removeLinkTo([InputsModelId.timeline, InputsModelId.global]));
|
||||
}
|
||||
}, [
|
||||
dataProviders,
|
||||
clearTimeline,
|
||||
dispatch,
|
||||
defaultDataView.id,
|
||||
signalIndexName,
|
||||
filters,
|
||||
timeRange,
|
||||
keepDataView,
|
||||
]);
|
||||
const { investigateInTimeline } = useInvestigateInTimeline();
|
||||
const openTimelineCallback = useCallback(() => {
|
||||
investigateInTimeline({
|
||||
dataProviders,
|
||||
filters,
|
||||
timeRange,
|
||||
keepDataView,
|
||||
});
|
||||
}, [dataProviders, filters, timeRange, keepDataView, investigateInTimeline]);
|
||||
|
||||
return asEmptyButton ? (
|
||||
<EuiButtonEmpty
|
||||
aria-label={ACTION_INVESTIGATE_IN_TIMELINE}
|
||||
onClick={configureAndOpenTimeline}
|
||||
onClick={openTimelineCallback}
|
||||
flush={flush ?? 'right'}
|
||||
size="xs"
|
||||
iconType={iconType}
|
||||
|
@ -127,11 +77,7 @@ export const InvestigateInTimelineButton: FC<
|
|||
{children}
|
||||
</EuiButtonEmpty>
|
||||
) : (
|
||||
<EuiButton
|
||||
aria-label={ACTION_INVESTIGATE_IN_TIMELINE}
|
||||
onClick={configureAndOpenTimeline}
|
||||
{...rest}
|
||||
>
|
||||
<EuiButton aria-label={ACTION_INVESTIGATE_IN_TIMELINE} onClick={openTimelineCallback} {...rest}>
|
||||
{children}
|
||||
</EuiButton>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { useCreateTimeline } from '../../../timelines/hooks/use_create_timeline';
|
||||
import { updateProviders, setFilters } from '../../../timelines/store/actions';
|
||||
import { SourcererScopeName } from '../../../sourcerer/store/model';
|
||||
import type { DataProvider } from '../../../../common/types';
|
||||
import { sourcererSelectors } from '../../store';
|
||||
import { sourcererActions } from '../../store/actions';
|
||||
import { inputsActions } from '../../store/inputs';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import type { TimeRange } from '../../store/inputs/model';
|
||||
import { TimelineId } from '../../../../common/types/timeline';
|
||||
import { TimelineTypeEnum } from '../../../../common/api/timeline';
|
||||
|
||||
interface InvestigateInTimelineArgs {
|
||||
/**
|
||||
* The data providers to apply to the timeline.
|
||||
*/
|
||||
dataProviders?: DataProvider[] | null;
|
||||
|
||||
/**
|
||||
* The filters to apply to the timeline.
|
||||
*/
|
||||
filters?: Filter[] | null;
|
||||
|
||||
/**
|
||||
* The time range to apply to the timeline, defaults to global time range.
|
||||
*/
|
||||
timeRange?: TimeRange;
|
||||
|
||||
/**
|
||||
* Whether to keep the current data view or reset it to the default.
|
||||
*/
|
||||
keepDataView?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook returns a callback that, when called, opens the timeline modal.
|
||||
* It clears the current timeline or timeline template.
|
||||
* Parameters can be passed to configure the timeline as it opens
|
||||
*/
|
||||
export const useInvestigateInTimeline = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
|
||||
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
|
||||
|
||||
const clearTimelineTemplate = useCreateTimeline({
|
||||
timelineId: TimelineId.active,
|
||||
timelineType: TimelineTypeEnum.template,
|
||||
});
|
||||
|
||||
const clearTimelineDefault = useCreateTimeline({
|
||||
timelineId: TimelineId.active,
|
||||
timelineType: TimelineTypeEnum.default,
|
||||
});
|
||||
|
||||
const investigateInTimeline = useCallback(
|
||||
async ({ dataProviders, filters, timeRange, keepDataView }: InvestigateInTimelineArgs) => {
|
||||
const hasTemplateProviders =
|
||||
dataProviders && dataProviders.find((provider) => provider.type === 'template');
|
||||
const clearTimeline = hasTemplateProviders ? clearTimelineTemplate : clearTimelineDefault;
|
||||
|
||||
if (dataProviders || filters) {
|
||||
// Reset the current timeline
|
||||
if (timeRange) {
|
||||
await clearTimeline({
|
||||
timeRange,
|
||||
});
|
||||
} else {
|
||||
await clearTimeline();
|
||||
}
|
||||
if (dataProviders) {
|
||||
// Update the timeline's providers to match the current prevalence field query
|
||||
dispatch(
|
||||
updateProviders({
|
||||
id: TimelineId.active,
|
||||
providers: dataProviders,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Use filters if more than a certain amount of ids for dom performance.
|
||||
if (filters) {
|
||||
dispatch(
|
||||
setFilters({
|
||||
id: TimelineId.active,
|
||||
filters,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Only show detection alerts
|
||||
// (This is required so the timeline event count matches the prevalence count)
|
||||
if (!keepDataView) {
|
||||
dispatch(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: defaultDataView.id,
|
||||
selectedPatterns: [signalIndexName || ''],
|
||||
})
|
||||
);
|
||||
}
|
||||
// Unlock the time range from the global time range
|
||||
dispatch(inputsActions.removeLinkTo([InputsModelId.timeline, InputsModelId.global]));
|
||||
}
|
||||
},
|
||||
[clearTimelineTemplate, clearTimelineDefault, dispatch, defaultDataView.id, signalIndexName]
|
||||
);
|
||||
|
||||
return { investigateInTimeline };
|
||||
};
|
|
@ -5,14 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import type { Filter, TimeRange } from '@kbn/es-query';
|
||||
import dateMath from '@kbn/datemath';
|
||||
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';
|
||||
import { useInvestigateInTimeline } from '../../../../common/hooks/timeline/use_investigate_in_timeline';
|
||||
import { normalizeTimeRange } from '../../../../common/utils/normalize_time_range';
|
||||
|
||||
const GraphInvestigationLazy = React.lazy(() =>
|
||||
import('@kbn/cloud-security-posture-graph').then((module) => ({
|
||||
|
@ -42,6 +46,35 @@ export const GraphVisualization: React.FC = memo(() => {
|
|||
});
|
||||
|
||||
const originEventIds = eventIds.map((id) => ({ id, isAlert }));
|
||||
const { investigateInTimeline } = useInvestigateInTimeline();
|
||||
const openTimelineCallback = useCallback(
|
||||
(filters: Filter[], timeRange: TimeRange) => {
|
||||
const from = dateMath.parse(timeRange.from);
|
||||
const to = dateMath.parse(timeRange.to);
|
||||
|
||||
if (!from || !to) {
|
||||
// TODO: show error message
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedTimeRange = normalizeTimeRange({
|
||||
...timeRange,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
|
||||
investigateInTimeline({
|
||||
keepDataView: true,
|
||||
filters,
|
||||
timeRange: {
|
||||
from: normalizedTimeRange.from,
|
||||
to: normalizedTimeRange.to,
|
||||
kind: 'absolute',
|
||||
},
|
||||
});
|
||||
},
|
||||
[investigateInTimeline]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -63,6 +96,8 @@ export const GraphVisualization: React.FC = memo(() => {
|
|||
to: `${timestamp}||+30m`,
|
||||
},
|
||||
}}
|
||||
showInvestigateInTimeline={true}
|
||||
onInvestigateInTimeline={openTimelineCallback}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
|
|
@ -19,9 +19,10 @@ const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID = `${GRAPH_INVESTIGATION_TEST_I
|
|||
const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity`;
|
||||
const GRAPH_LABEL_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphLabelExpandPopover`;
|
||||
const GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowEventsWithThisAction`;
|
||||
const GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID = `${GRAPH_INVESTIGATION_TEST_ID}InvestigateInTimeline`;
|
||||
type Filter = Parameters<FilterBarService['addFilter']>[0];
|
||||
|
||||
export class ExpandedFlyout extends FtrService {
|
||||
export class ExpandedFlyoutGraph extends FtrService {
|
||||
private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']);
|
||||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
private readonly filterBar = this.ctx.getService('filterBar');
|
||||
|
@ -120,4 +121,9 @@ export class ExpandedFlyout extends FtrService {
|
|||
await this.testSubjects.clickWhenNotDisabled('saveFilter');
|
||||
await this.pageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async clickOnInvestigateInTimelineButton(): Promise<void> {
|
||||
await this.testSubjects.click(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID);
|
||||
await this.pageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
}
|
|
@ -15,12 +15,14 @@ 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';
|
||||
import { ExpandedFlyoutGraph } from './expanded_flyout_graph';
|
||||
import { TimelinePageObject } from './timeline_page';
|
||||
|
||||
export const cloudSecurityPosturePageObjects = {
|
||||
alerts: AlertsPageObject,
|
||||
networkEvents: NetworkEventsPageObject,
|
||||
expandedFlyout: ExpandedFlyout,
|
||||
expandedFlyoutGraph: ExpandedFlyoutGraph,
|
||||
timeline: TimelinePageObject,
|
||||
findings: FindingsPageProvider,
|
||||
cloudPostureDashboard: CspDashboardPageProvider,
|
||||
cisAddIntegration: AddCisIntegrationFormPageProvider,
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 { subj as testSubjSelector } from '@kbn/test-subj-selector';
|
||||
import { FtrService } from '../../functional/ftr_provider_context';
|
||||
|
||||
const TIMELINE_CLOSE_BUTTON_TEST_SUBJ = 'timeline-modal-header-close-button';
|
||||
const TIMELINE_MODAL_PAGE_TEST_SUBJ = 'timeline';
|
||||
const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query';
|
||||
|
||||
const TIMELINE_CSS_SELECTOR = Object.freeze({
|
||||
/** The refresh button on the timeline view (top of view, next to the date selector) */
|
||||
refreshButton: `${testSubjSelector(TIMELINE_TAB_QUERY_TEST_SUBJ)} ${testSubjSelector(
|
||||
'superDatePickerApplyTimeButton'
|
||||
)} `,
|
||||
});
|
||||
|
||||
export class TimelinePageObject extends FtrService {
|
||||
private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']);
|
||||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
private readonly retry = this.ctx.getService('retry');
|
||||
private readonly defaultTimeoutMs = this.ctx.getService('config').get('timeouts.waitFor');
|
||||
private readonly logger = this.ctx.getService('log');
|
||||
|
||||
async closeTimeline(): Promise<void> {
|
||||
if (await this.testSubjects.exists(TIMELINE_CLOSE_BUTTON_TEST_SUBJ)) {
|
||||
await this.testSubjects.click(TIMELINE_CLOSE_BUTTON_TEST_SUBJ);
|
||||
await this.testSubjects.waitForHidden(TIMELINE_MODAL_PAGE_TEST_SUBJ);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureTimelineIsOpen(): Promise<void> {
|
||||
await this.testSubjects.existOrFail(TIMELINE_MODAL_PAGE_TEST_SUBJ);
|
||||
}
|
||||
|
||||
/**
|
||||
* From a visible timeline, clicks the "view details" for an event on the list
|
||||
* @param index
|
||||
*/
|
||||
async showEventDetails(index: number = 0): Promise<void> {
|
||||
await this.ensureTimelineIsOpen();
|
||||
await this.testSubjects.findService.clickByCssSelector(
|
||||
`${testSubjSelector('event')}:nth-child(${index + 1}) ${testSubjSelector('expand-event')}`
|
||||
);
|
||||
await this.testSubjects.existOrFail('eventDetails');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Refresh button at the top of the timeline page and waits for the refresh to complete
|
||||
*/
|
||||
async clickRefresh(): Promise<void> {
|
||||
await this.ensureTimelineIsOpen();
|
||||
await this.pageObjects.header.waitUntilLoadingHasFinished();
|
||||
await (
|
||||
await this.testSubjects.findService.byCssSelector(TIMELINE_CSS_SELECTOR.refreshButton)
|
||||
).isEnabled();
|
||||
await this.testSubjects.findService.clickByCssSelector(TIMELINE_CSS_SELECTOR.refreshButton);
|
||||
await this.retry.waitFor(
|
||||
'Timeline refresh button to be enabled',
|
||||
async (): Promise<boolean> => {
|
||||
return (
|
||||
await this.testSubjects.findService.byCssSelector(TIMELINE_CSS_SELECTOR.refreshButton)
|
||||
).isEnabled();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if the timeline has events in the list
|
||||
*/
|
||||
async hasEvents(): Promise<boolean> {
|
||||
const eventRows = await this.testSubjects.findService.allByCssSelector(
|
||||
`${testSubjSelector(TIMELINE_MODAL_PAGE_TEST_SUBJ)} [role="row"]`
|
||||
);
|
||||
|
||||
return eventRows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for events to be displayed in the timeline. It will click on the "Refresh" button to trigger a data fetch
|
||||
* @param timeoutMs
|
||||
*/
|
||||
async waitForEvents(timeoutMs?: number): Promise<void> {
|
||||
if (await this.hasEvents()) {
|
||||
this.logger.info(`Timeline already has events displayed`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.retry.waitForWithTimeout(
|
||||
'waiting for events to show up on timeline',
|
||||
timeoutMs ?? this.defaultTimeoutMs,
|
||||
async (): Promise<boolean> => {
|
||||
await this.clickRefresh();
|
||||
|
||||
return this.hasEvents();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -14,9 +14,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
const logger = getService('log');
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const pageObjects = getPageObjects(['common', 'header', 'alerts', 'expandedFlyout']);
|
||||
const pageObjects = getPageObjects([
|
||||
'common',
|
||||
'header',
|
||||
'alerts',
|
||||
'expandedFlyoutGraph',
|
||||
'timeline',
|
||||
]);
|
||||
const alertsPage = pageObjects.alerts;
|
||||
const expandedFlyout = pageObjects.expandedFlyout;
|
||||
const expandedFlyoutGraph = pageObjects.expandedFlyoutGraph;
|
||||
const timelinePage = pageObjects.timeline;
|
||||
|
||||
describe('Security Alerts Page - Graph visualization', function () {
|
||||
this.tags(['cloud_security_posture_graph_viz']);
|
||||
|
@ -59,61 +66,66 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
await alertsPage.flyout.assertGraphPreviewVisible();
|
||||
await alertsPage.flyout.assertGraphNodesNumber(3);
|
||||
|
||||
await expandedFlyout.expandGraph();
|
||||
await expandedFlyout.waitGraphIsLoaded();
|
||||
await expandedFlyout.assertGraphNodesNumber(3);
|
||||
await expandedFlyoutGraph.expandGraph();
|
||||
await expandedFlyoutGraph.waitGraphIsLoaded();
|
||||
await expandedFlyoutGraph.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');
|
||||
await expandedFlyoutGraph.showActionsByEntity('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com');
|
||||
|
||||
// Show actions on entity
|
||||
await expandedFlyout.showActionsOnEntity('admin@example.com');
|
||||
await expandedFlyout.expectFilterTextEquals(
|
||||
await expandedFlyoutGraph.showActionsOnEntity('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.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(
|
||||
await expandedFlyoutGraph.exploreRelatedEntities('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
|
||||
);
|
||||
|
||||
// Show events with the same action
|
||||
await expandedFlyout.showEventsOfSameAction(
|
||||
await expandedFlyoutGraph.showEventsOfSameAction(
|
||||
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
|
||||
);
|
||||
await expandedFlyout.expectFilterTextEquals(
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
|
||||
);
|
||||
|
||||
// Clear filters
|
||||
await expandedFlyout.clearAllFilters();
|
||||
await expandedFlyoutGraph.clearAllFilters();
|
||||
|
||||
// Add custom filter
|
||||
await expandedFlyout.addFilter({
|
||||
await expandedFlyoutGraph.addFilter({
|
||||
field: 'actor.entity.id',
|
||||
operation: 'is',
|
||||
value: 'admin2@example.com',
|
||||
});
|
||||
await pageObjects.header.waitUntilLoadingHasFinished();
|
||||
await expandedFlyout.assertGraphNodesNumber(5);
|
||||
await expandedFlyoutGraph.assertGraphNodesNumber(5);
|
||||
|
||||
// Open timeline
|
||||
await expandedFlyoutGraph.clickOnInvestigateInTimelineButton();
|
||||
await timelinePage.ensureTimelineIsOpen();
|
||||
await timelinePage.waitForEvents();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,9 +14,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
const logger = getService('log');
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const pageObjects = getPageObjects(['common', 'header', 'networkEvents', 'expandedFlyout']);
|
||||
const pageObjects = getPageObjects([
|
||||
'common',
|
||||
'header',
|
||||
'networkEvents',
|
||||
'expandedFlyoutGraph',
|
||||
'timeline',
|
||||
]);
|
||||
const networkEventsPage = pageObjects.networkEvents;
|
||||
const expandedFlyout = pageObjects.expandedFlyout;
|
||||
const expandedFlyoutGraph = pageObjects.expandedFlyoutGraph;
|
||||
const timelinePage = pageObjects.timeline;
|
||||
|
||||
describe('Security Network Page - Graph visualization', function () {
|
||||
this.tags(['cloud_security_posture_graph_viz']);
|
||||
|
@ -51,61 +58,66 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
await networkEventsPage.flyout.assertGraphPreviewVisible();
|
||||
await networkEventsPage.flyout.assertGraphNodesNumber(3);
|
||||
|
||||
await expandedFlyout.expandGraph();
|
||||
await expandedFlyout.waitGraphIsLoaded();
|
||||
await expandedFlyout.assertGraphNodesNumber(3);
|
||||
await expandedFlyoutGraph.expandGraph();
|
||||
await expandedFlyoutGraph.waitGraphIsLoaded();
|
||||
await expandedFlyoutGraph.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');
|
||||
await expandedFlyoutGraph.showActionsByEntity('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com');
|
||||
|
||||
// Show actions on entity
|
||||
await expandedFlyout.showActionsOnEntity('admin@example.com');
|
||||
await expandedFlyout.expectFilterTextEquals(
|
||||
await expandedFlyoutGraph.showActionsOnEntity('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.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(
|
||||
await expandedFlyoutGraph.exploreRelatedEntities('admin@example.com');
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
|
||||
);
|
||||
|
||||
// Show events with the same action
|
||||
await expandedFlyout.showEventsOfSameAction(
|
||||
await expandedFlyoutGraph.showEventsOfSameAction(
|
||||
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
|
||||
);
|
||||
await expandedFlyout.expectFilterTextEquals(
|
||||
await expandedFlyoutGraph.expectFilterTextEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
|
||||
);
|
||||
await expandedFlyout.expectFilterPreviewEquals(
|
||||
await expandedFlyoutGraph.expectFilterPreviewEquals(
|
||||
0,
|
||||
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
|
||||
);
|
||||
|
||||
// Clear filters
|
||||
await expandedFlyout.clearAllFilters();
|
||||
await expandedFlyoutGraph.clearAllFilters();
|
||||
|
||||
// Add custom filter
|
||||
await expandedFlyout.addFilter({
|
||||
await expandedFlyoutGraph.addFilter({
|
||||
field: 'actor.entity.id',
|
||||
operation: 'is',
|
||||
value: 'admin2@example.com',
|
||||
});
|
||||
await pageObjects.header.waitUntilLoadingHasFinished();
|
||||
await expandedFlyout.assertGraphNodesNumber(5);
|
||||
await expandedFlyoutGraph.assertGraphNodesNumber(5);
|
||||
|
||||
// Open timeline
|
||||
await expandedFlyoutGraph.clickOnInvestigateInTimelineButton();
|
||||
await timelinePage.ensureTimelineIsOpen();
|
||||
await timelinePage.waitForEvents();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue