[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:
Kfir Peled 2025-01-06 17:51:05 +01:00 committed by GitHub
parent eafa15bba6
commit 301f91da2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1530 additions and 199 deletions

View file

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

View file

@ -1,3 +0,0 @@
# @kbn/cloud-security-posture-storybook-config
Storybook configuration used by `yarn storybook`. Refer to `@kbn/storybook` for more information.

View file

@ -1,6 +0,0 @@
{
"name": "@kbn/cloud-security-posture-storybook-config",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -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/**/*"]
}

View file

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

View file

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

View file

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

View file

@ -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),
};

View file

@ -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.

View file

@ -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: [

View file

@ -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,
};

View file

@ -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();
});
});

View file

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

View file

@ -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,
};

View file

@ -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();
});
});

View file

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

View file

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

View file

@ -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>

View file

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

View file

@ -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();
});
});

View file

@ -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,
};

View file

@ -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 />

View file

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

View file

@ -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'),
}),
[]
);

View file

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

View file

@ -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({});

View file

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

View file

@ -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"
]
}

View file

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

View file

@ -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 };
};

View file

@ -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>
)}

View file

@ -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();
}
}

View file

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

View file

@ -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();
}
);
}
}

View file

@ -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();
});
});
}

View file

@ -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();
});
});
}