[AI4DSOC] Alert summary page performance improvements (#218632)

## Summary

This PR applies some performance improvements to the newly created Alert
summary page (for AI for SOC).

Here are the multiple changes:
- instead of fetching all rules in multiple places (components and
hooks), we're now fetching all rules in the most top level
`alert_summary.tsx` pages component. We're then passing the result down
via props to the children components. Though some of the components
inside the `alerts_table` component for example cannot be passed via
props, so we're leveraging the `additionalContext` property to pass down
rules information. Also, for the components working within the
`grouping_alerts_table`, we had to wrap the whole component with a local
context.
- similarly, the packages were already fetched in the very top
`alert_summary.tsx` pages component and were passed via props to the
children components, but we applied the same logic for the
`alerts_table` and the `grouping_alerts_table` components.

The PR also improves the `integration_icon.tsx` component to make it
more generic, and reused in all places to avoid the previous code
duplication.

**No UI or behavior changes are introduced!**


https://github.com/user-attachments/assets/1fc1b6d0-290c-4b8e-b3e1-6ccb82f4f82b

## How to test

This needs to be ran in Serverless:
- `yarn es serverless --projectType security`
- `yarn serverless-security --no-base-path`

You also need to enable the AI for SOC tier, by adding the following to
your `serverless.security.dev.yaml` file:
```
xpack.securitySolutionServerless.productTypes:
  [
    { product_line: 'ai_soc', product_tier: 'search_ai_lake' },
  ]
```

Use one of these Serverless users:
- `platform_engineer`
- `endpoint_operations_analyst`
- `endpoint_policy_manager`
- `admin`
- `system_indices_superuser`

Then:
- generate data: `yarn test:generate:serverless-dev`
- create 4 catch all rules, each with a name of a AI for SOC integration
(`google_secops`, `microsoft_sentinel`,, `sentinel_one` and
`crowdstrike`)
- change [this
line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts#L73)
to `installedPackages: availablePackages` to force having some packages
installed
- change [this
line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts#L63)
to `r.name === p.name` to make sure there will be matches between
integrations and rules


### Checklist

- [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
This commit is contained in:
Philippe Oberti 2025-04-18 20:31:52 +02:00 committed by GitHub
parent 6e4a06ee0f
commit 112eab3a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 927 additions and 407 deletions

View file

@ -8,51 +8,63 @@
import { render } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import React from 'react';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
import {
INTEGRATION_INTEGRATION_ICON_TEST_ID,
INTEGRATION_ICON_TEST_ID,
INTEGRATION_LOADING_SKELETON_TEST_ID,
IntegrationIcon,
} from './integration_icon';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id');
jest.mock('@kbn/fleet-plugin/public/hooks');
const testId = 'testid';
const integration: PackageListItem = {
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
};
describe('IntegrationIcon', () => {
it('should return a single integration icon', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {
title: 'title',
icons: [{ type: 'type', src: 'src' }],
name: 'name',
version: 'version',
},
isLoading: false,
});
beforeEach(() => {
jest.clearAllMocks();
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
const { getByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon ruleId={'name'} />
</IntlProvider>
);
expect(getByTestId(INTEGRATION_INTEGRATION_ICON_TEST_ID)).toBeInTheDocument();
});
it('should return a single integration loading', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {},
isLoading: true,
});
it('should render a single integration icon', () => {
const { getByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon ruleId={''} />
<IntegrationIcon data-test-subj={testId} integration={integration} />
</IntlProvider>
);
expect(getByTestId(INTEGRATION_LOADING_SKELETON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(`${testId}-${INTEGRATION_ICON_TEST_ID}`)).toBeInTheDocument();
});
it('should render the loading skeleton', () => {
const { getByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon data-test-subj={testId} integration={undefined} isLoading={true} />
</IntlProvider>
);
expect(getByTestId(`${testId}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`)).toBeInTheDocument();
});
it('should not render skeleton or icon', () => {
const { queryByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon data-test-subj={testId} integration={undefined} />
</IntlProvider>
);
expect(queryByTestId(`${testId}-${INTEGRATION_ICON_TEST_ID}`)).not.toBeInTheDocument();
expect(
queryByTestId(`${testId}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`)
).not.toBeInTheDocument();
});
});

View file

@ -9,39 +9,49 @@ import React, { memo } from 'react';
import { EuiSkeletonText } from '@elastic/eui';
import { CardIcon } from '@kbn/fleet-plugin/public';
import type { IconSize } from '@elastic/eui/src/components/icon/icon';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
export const INTEGRATION_LOADING_SKELETON_TEST_ID = 'ai-for-soc-alert-integration-loading-skeleton';
export const INTEGRATION_INTEGRATION_ICON_TEST_ID = 'ai-for-soc-alert-integration-icon';
export const INTEGRATION_LOADING_SKELETON_TEST_ID = 'integration-loading-skeleton';
export const INTEGRATION_ICON_TEST_ID = 'integration-icon';
interface IntegrationProps {
/**
* Id of the rule the alert was generated by
* Optional data test subject string
*/
ruleId: string;
'data-test-subj'?: string;
/**
* Changes the size of the icon. Uses the Eui IconSize interface.
* Defaults to s
*/
iconSize?: IconSize;
/**
* Id of the rule the alert was generated by
*/
integration: PackageListItem | undefined;
/**
* If true, renders a EuiSkeletonText
*/
isLoading?: boolean;
}
/**
* Renders the icon for the integration that matches the rule id.
* In AI for SOC, we can retrieve the integration/package that matches a specific rule, via the related_integrations field on the rule.
* Renders the icon for the integration. Renders a EuiSkeletonText if loading.
*/
export const IntegrationIcon = memo(({ ruleId, iconSize = 's' }: IntegrationProps) => {
const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId });
return (
export const IntegrationIcon = memo(
({
'data-test-subj': dataTestSubj,
iconSize = 's',
integration,
isLoading = false,
}: IntegrationProps) => (
<EuiSkeletonText
data-test-subj={INTEGRATION_LOADING_SKELETON_TEST_ID}
data-test-subj={`${dataTestSubj}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`}
isLoading={isLoading}
lines={1}
>
{integration ? (
<CardIcon
data-test-subj={INTEGRATION_INTEGRATION_ICON_TEST_ID}
data-test-subj={`${dataTestSubj}-${INTEGRATION_ICON_TEST_ID}`}
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
@ -50,6 +60,7 @@ export const IntegrationIcon = memo(({ ruleId, iconSize = 's' }: IntegrationProp
/>
) : null}
</EuiSkeletonText>
);
});
)
);
IntegrationIcon.displayName = 'IntegrationIcon';

View file

@ -17,7 +17,7 @@ import {
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { CardIcon } from '@kbn/fleet-plugin/public';
import { IntegrationIcon } from '../common/integration_icon';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
const LAST_SYNCED = i18n.translate(
@ -70,12 +70,10 @@ export const IntegrationCard = memo(
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<CardIcon
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="xl"
version={integration.version}
<IntegrationIcon
data-test-subj={dataTestSubj}
iconSize="xl"
integration={integration}
/>
</EuiFlexItem>
<EuiFlexItem>

View file

@ -10,8 +10,9 @@ import { css } from '@emotion/react';
import { EuiBadge, EuiCard } from '@elastic/eui';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { INTEGRATIONS_PLUGIN_ID } from '@kbn/fleet-plugin/common';
import { CardIcon, useLink } from '@kbn/fleet-plugin/public';
import { useLink } from '@kbn/fleet-plugin/public';
import { i18n } from '@kbn/i18n';
import { IntegrationIcon } from '../common/integration_icon';
import { useKibana } from '../../../../common/lib/kibana';
const SIEM_BADGE = i18n.translate('xpack.securitySolution.alertSummary.integrations.siemBadge', {
@ -65,13 +66,7 @@ export const IntegrationCard = memo(
display="plain"
hasBorder
icon={
<CardIcon
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="xl"
version={integration.version}
/>
<IntegrationIcon data-test-subj={dataTestSubj} iconSize="xl" integration={integration} />
}
layout="horizontal"
onClick={onClick}

View file

@ -37,8 +37,16 @@ const packages: PackageListItem[] = [
version: '',
},
];
const ruleResponse = {
rules: [],
isLoading: false,
};
describe('<SearchBarSection />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render all components', () => {
(useIntegrations as jest.Mock).mockReturnValue({
isLoading: false,
@ -49,7 +57,7 @@ describe('<SearchBarSection />', () => {
});
const { getByTestId, queryByTestId } = render(
<SearchBarSection dataView={dataView} packages={packages} />
<SearchBarSection dataView={dataView} packages={packages} ruleResponse={ruleResponse} />
);
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
@ -64,7 +72,7 @@ describe('<SearchBarSection />', () => {
});
const { getByTestId, queryByTestId } = render(
<SearchBarSection dataView={dataView} packages={packages} />
<SearchBarSection dataView={dataView} packages={packages} ruleResponse={ruleResponse} />
);
expect(getByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).toBeInTheDocument();

View file

@ -9,6 +9,7 @@ import React, { memo, useMemo } from 'react';
import type { DataView } from '@kbn/data-views-plugin/common';
import { EuiFlexGroup, EuiFlexItem, EuiSkeletonRectangle } from '@elastic/eui';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
import { useIntegrations } from '../../../hooks/alert_summary/use_integrations';
import { SiemSearchBar } from '../../../../common/components/search_bar';
import { IntegrationFilterButton } from './integrations_filter_button';
@ -29,6 +30,19 @@ export interface SearchBarSectionProps {
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
/**
@ -38,34 +52,36 @@ export interface SearchBarSectionProps {
* For the AI for SOC effort, each integration has one rule associated with.
* This means that deselecting an integration is equivalent to filtering out by the rule for that integration.
*/
export const SearchBarSection = memo(({ dataView, packages }: SearchBarSectionProps) => {
const { isLoading, integrations } = useIntegrations({ packages });
export const SearchBarSection = memo(
({ dataView, packages, ruleResponse }: SearchBarSectionProps) => {
const { isLoading, integrations } = useIntegrations({ packages, ruleResponse });
const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]);
const dataViewSpec = useMemo(() => dataView.toSpec(), [dataView]);
return (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSkeletonRectangle
data-test-subj={INTEGRATION_BUTTON_LOADING_TEST_ID}
isLoading={isLoading}
width={INTEGRATION_BUTTON_LOADING_WIDTH}
height={INTEGRATION_BUTTON_LOADING_HEIGHT}
>
<IntegrationFilterButton integrations={integrations} />
</EuiSkeletonRectangle>
</EuiFlexItem>
<EuiFlexItem>
<SiemSearchBar
dataTestSubj={SEARCH_BAR_TEST_ID}
hideFilterBar
hideQueryMenu
id={InputsModelId.global}
sourcererDataView={dataViewSpec}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
});
return (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSkeletonRectangle
data-test-subj={INTEGRATION_BUTTON_LOADING_TEST_ID}
isLoading={isLoading}
width={INTEGRATION_BUTTON_LOADING_WIDTH}
height={INTEGRATION_BUTTON_LOADING_HEIGHT}
>
<IntegrationFilterButton integrations={integrations} />
</EuiSkeletonRectangle>
</EuiFlexItem>
<EuiFlexItem>
<SiemSearchBar
dataTestSubj={SEARCH_BAR_TEST_ID}
hideFilterBar
hideQueryMenu
id={InputsModelId.global}
sourcererDataView={dataViewSpec}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
SearchBarSection.displayName = 'SearchBarSection';

View file

@ -11,7 +11,6 @@ import type { Alert } from '@kbn/alerting-types';
import { BasicCellRenderer } from './basic_cell_renderer';
import { TestProviders } from '../../../../common/mock';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { CellValue } from './render_cell';
describe('BasicCellRenderer', () => {
it('should handle missing field', () => {
@ -58,7 +57,7 @@ describe('BasicCellRenderer', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<BasicCellRenderer alert={alert} field={columnId} />
</TestProviders>
);

View file

@ -5,13 +5,39 @@
* 2.0.
*/
import { getIntegrationComponent, groupStatsRenderer } from './group_stats_renderers';
import React from 'react';
import { render } from '@testing-library/react';
import {
getIntegrationComponent,
groupStatsRenderer,
IntegrationIcon,
TABLE_GROUP_STATS_TEST_ID,
} from './group_stats_renderers';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { useTableSectionContext } from './table_section_context';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
import { INTEGRATION_ICON_TEST_ID } from '../common/integration_icon';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id');
jest.mock('@kbn/fleet-plugin/public/hooks');
jest.mock('./table_section_context');
const integration: PackageListItem = {
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
};
describe('getIntegrationComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return an empty array', () => {
const groupStatsItems = getIntegrationComponent({
key: '',
@ -66,7 +92,51 @@ describe('getIntegrationComponent', () => {
});
});
describe('IntegrationIcon', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render integration icon', () => {
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: {},
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration,
});
const { getByTestId } = render(<IntegrationIcon ruleId={'ruleId'} />);
expect(
getByTestId(`${TABLE_GROUP_STATS_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`)
).toBeInTheDocument();
});
it('should not render icon', () => {
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: {},
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: undefined,
});
const { queryByTestId } = render(<IntegrationIcon ruleId={'ruleId'} />);
expect(
queryByTestId(`${TABLE_GROUP_STATS_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`)
).not.toBeInTheDocument();
});
});
describe('groupStatsRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return array of badges for signal.rule.id field', () => {
const badges = groupStatsRenderer('signal.rule.id', {
key: '',

View file

@ -6,12 +6,14 @@
*/
import type { GroupStatsItem, RawBucket } from '@kbn/grouping';
import React from 'react';
import React, { memo } from 'react';
import { i18n } from '@kbn/i18n';
import { IntegrationIcon } from '../common/integration_icon';
import { IntegrationIcon as Icon } from '../common/integration_icon';
import { useTableSectionContext } from './table_section_context';
import { getRulesBadge, getSeverityComponent } from '../../alerts_table/grouping_settings';
import { DEFAULT_GROUP_STATS_RENDERER } from '../../alerts_table/alerts_grouping';
import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
const STATS_GROUP_SIGNAL_RULE_ID = i18n.translate(
'xpack.securitySolution.alertSummary.groups.integrations',
@ -26,6 +28,32 @@ const STATS_GROUP_SIGNAL_RULE_ID_MULTI = i18n.translate(
}
);
export const TABLE_GROUP_STATS_TEST_ID = 'ai-for-soc-alert-table-group-stats';
interface IntegrationProps {
/**
* Id of the rule the alert was generated by
*/
ruleId: string;
}
/**
* Renders the icon for the integration that matches the rule id.
* In AI for SOC, we can retrieve the integration/package that matches a specific rule, via the related_integrations field on the rule.
*/
export const IntegrationIcon = memo(({ ruleId }: IntegrationProps) => {
const { packages, ruleResponse } = useTableSectionContext();
const { integration } = useGetIntegrationFromRuleId({
packages,
rules: ruleResponse.rules,
ruleId,
});
return <Icon data-test-subj={TABLE_GROUP_STATS_TEST_ID} integration={integration} />;
});
IntegrationIcon.displayName = 'IntegrationIcon';
/**
* Return a renderer for integration aggregation.
*/

View file

@ -18,15 +18,36 @@ import { render } from '@testing-library/react';
import { defaultGroupTitleRenderers } from '../../alerts_table/grouping_settings';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import React from 'react';
import { useTableSectionContext } from './table_section_context';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_rule_id');
jest.mock('./table_section_context');
jest.mock('@kbn/fleet-plugin/public/hooks');
const integration: PackageListItem = {
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
};
describe('groupTitleRenderers', () => {
beforeEach(() => {
jest.clearAllMocks();
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
});
it('should render correctly for signal.rule.id field', () => {
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: { title: 'rule_name' },
isLoading: false,
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: { isLoading: false },
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({ integration });
const { getByTestId } = render(
groupTitleRenderers(
@ -117,10 +138,18 @@ describe('groupTitleRenderers', () => {
});
describe('IntegrationNameGroupContent', () => {
beforeEach(() => {
jest.clearAllMocks();
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
});
it('should render the integration name and icon when a matching rule is found', () => {
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: { isLoading: false },
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: { title: 'rule_name', icons: 'icon' },
isLoading: false,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);
@ -134,9 +163,12 @@ describe('IntegrationNameGroupContent', () => {
});
it('should render rule id when no matching rule is found', () => {
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: { isLoading: false },
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: undefined,
isLoading: false,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);
@ -152,9 +184,12 @@ describe('IntegrationNameGroupContent', () => {
});
it('should render loading for signal.rule.id field when rule and packages are loading', () => {
(useTableSectionContext as jest.Mock).mockReturnValue({
packages: [],
ruleResponse: { isLoading: true },
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: undefined,
isLoading: true,
});
const { getByTestId, queryByTestId } = render(<IntegrationNameGroupContent title="rule.id" />);

View file

@ -9,7 +9,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiTitle } from '@elastic/e
import { isArray } from 'lodash/fp';
import React, { memo } from 'react';
import type { GroupPanelRenderer } from '@kbn/grouping/src';
import { CardIcon } from '@kbn/fleet-plugin/public';
import { IntegrationIcon } from '../common/integration_icon';
import { useTableSectionContext } from './table_section_context';
import { useGetIntegrationFromRuleId } from '../../../hooks/alert_summary/use_get_integration_from_rule_id';
import { GroupWithIconContent, RuleNameGroupContent } from '../../alerts_table/grouping_settings';
import type { AlertsGroupingAggregation } from '../../alerts_table/grouping_settings/types';
@ -79,22 +80,27 @@ export const INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID = 'integration-group-ren
export const INTEGRATION_GROUP_RENDERER_TEST_ID = 'integration-group-renderer';
export const INTEGRATION_GROUP_RENDERER_INTEGRATION_NAME_TEST_ID =
'integration-group-renderer-integration-name';
export const INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID =
'integration-group-renderer-integration-icon';
export const INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID = 'integration-group-renderer';
export const SIGNAL_RULE_ID_GROUP_RENDERER_TEST_ID = 'signal-rule-id-group-renderer';
/**
* Renders an icon and name of an integration.
* This component needs to be used within the TableSectionContext which provides the installed packages as well as all the rules.
*/
export const IntegrationNameGroupContent = memo<{
title: string | string[];
}>(({ title }) => {
const { integration, isLoading } = useGetIntegrationFromRuleId({ ruleId: title });
const { packages, ruleResponse } = useTableSectionContext();
const { integration } = useGetIntegrationFromRuleId({
packages,
ruleId: title,
rules: ruleResponse.rules,
});
return (
<EuiSkeletonText
data-test-subj={INTEGRATION_GROUP_RENDERER_LOADING_TEST_ID}
isLoading={isLoading}
isLoading={ruleResponse.isLoading}
lines={1}
>
{integration ? (
@ -104,13 +110,10 @@ export const IntegrationNameGroupContent = memo<{
alignItems="center"
>
<EuiFlexItem grow={false}>
<CardIcon
<IntegrationIcon
data-test-subj={INTEGRATION_GROUP_RENDERER_INTEGRATION_ICON_TEST_ID}
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="xl"
version={integration.version}
iconSize="xl"
integration={integration}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -9,88 +9,92 @@ import React from 'react';
import { render } from '@testing-library/react';
import type { Alert } from '@kbn/alerting-types';
import {
ICON_TEST_ID,
KibanaAlertRelatedIntegrationsCellRenderer,
SKELETON_TEST_ID,
TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID,
} from './kibana_alert_related_integrations_cell_renderer';
import { useGetIntegrationFromPackageName } from '../../../hooks/alert_summary/use_get_integration_from_package_name';
import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import {
INTEGRATION_ICON_TEST_ID,
INTEGRATION_LOADING_SKELETON_TEST_ID,
} from '../common/integration_icon';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_package_name');
jest.mock('@kbn/fleet-plugin/public/hooks');
const LOADING_SKELETON_TEST_ID = `${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`;
const ICON_TEST_ID = `${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`;
describe('KibanaAlertRelatedIntegrationsCellRenderer', () => {
it('should handle missing field', () => {
(useGetIntegrationFromPackageName as jest.Mock).mockReturnValue({
integration: null,
isLoading: false,
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle missing field', () => {
const alert: Alert = {
_id: '_id',
_index: '_index',
};
const packages: PackageListItem[] = [];
const { queryByTestId } = render(<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} />);
const { queryByTestId } = render(
<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} packages={packages} />
);
expect(queryByTestId(SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument();
});
it('should handle not finding matching integration', () => {
(useGetIntegrationFromPackageName as jest.Mock).mockReturnValue({
integration: null,
isLoading: false,
});
const alert: Alert = {
_id: '_id',
_index: '_index',
[ALERT_RULE_PARAMETERS]: ['splunk'],
[ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }],
};
const packages: PackageListItem[] = [
{
id: 'other',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'other',
status: installationStatuses.NotInstalled,
title: 'Other',
version: '0.1.0',
},
];
const { queryByTestId } = render(<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} />);
expect(queryByTestId(SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument();
});
it('should show loading', () => {
(useGetIntegrationFromPackageName as jest.Mock).mockReturnValue({
integration: null,
isLoading: true,
});
const alert: Alert = {
_id: '_id',
_index: '_index',
[ALERT_RULE_PARAMETERS]: ['splunk'],
};
const { getByTestId, queryByTestId } = render(
<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} />
const { queryByTestId } = render(
<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} packages={packages} />
);
expect(getByTestId(SKELETON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument();
});
it('should show integration icon', () => {
(useGetIntegrationFromPackageName as jest.Mock).mockReturnValue({
integration: { name: 'Splunk', icon: ['icon'] },
isLoading: false,
});
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
const alert: Alert = {
_id: '_id',
_index: '_index',
[ALERT_RULE_PARAMETERS]: ['splunk'],
[ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }],
};
const packages: PackageListItem[] = [
{
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
},
];
const { getByTestId, queryByTestId } = render(
<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} />
<KibanaAlertRelatedIntegrationsCellRenderer alert={alert} packages={packages} />
);
expect(queryByTestId(SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(LOADING_SKELETON_TEST_ID)).not.toBeInTheDocument();
expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -7,26 +7,28 @@
import React, { memo, useMemo } from 'react';
import type { JsonValue } from '@kbn/utility-types';
import { CardIcon } from '@kbn/fleet-plugin/public';
import { EuiSkeletonText } from '@elastic/eui';
import type { Alert } from '@kbn/alerting-types';
import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import { useGetIntegrationFromPackageName } from '../../../hooks/alert_summary/use_get_integration_from_package_name';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { IntegrationIcon } from '../common/integration_icon';
import { getAlertFieldValueAsStringOrNull, isJsonObjectValue } from '../../../utils/type_utils';
export const SKELETON_TEST_ID = 'alert-summary-table-related-integrations-cell-renderer-skeleton';
export const ICON_TEST_ID = 'alert-summary-table-related-integrations-cell-renderer-icon';
export const TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID =
'alert-summary-table-related-integrations-cell-renderer';
const RELATED_INTEGRATIONS_FIELD = 'related_integrations';
const PACKAGE_FIELD = 'package';
// function is_string(value: unknown): value is string {}
export interface KibanaAlertRelatedIntegrationsCellRendererProps {
/**
* Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface
*/
alert: Alert;
/**
* List of installed AI for SOC integrations.
* This comes from the additionalContext property on the table.
*/
packages: PackageListItem[];
}
/**
@ -35,7 +37,7 @@ export interface KibanaAlertRelatedIntegrationsCellRendererProps {
* Used in AI for SOC alert summary table.
*/
export const KibanaAlertRelatedIntegrationsCellRenderer = memo(
({ alert }: KibanaAlertRelatedIntegrationsCellRendererProps) => {
({ alert, packages }: KibanaAlertRelatedIntegrationsCellRendererProps) => {
const packageName: string | null = useMemo(() => {
const values: JsonValue[] | undefined = alert[ALERT_RULE_PARAMETERS];
@ -52,21 +54,17 @@ export const KibanaAlertRelatedIntegrationsCellRenderer = memo(
return null;
}, [alert]);
const { integration, isLoading } = useGetIntegrationFromPackageName({ packageName });
const integration = useMemo(
() => packages.find((p) => p.name === packageName),
[packages, packageName]
);
return (
<EuiSkeletonText data-test-subj={SKELETON_TEST_ID} isLoading={isLoading} lines={1}>
{integration ? (
<CardIcon
data-test-subj={ICON_TEST_ID}
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="l"
version={integration.version}
/>
) : null}
</EuiSkeletonText>
<IntegrationIcon
data-test-subj={TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}
iconSize="l"
integration={integration}
/>
);
}
);

View file

@ -12,13 +12,28 @@ import { CellValue } from './render_cell';
import { TestProviders } from '../../../../common/mock';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { ALERT_RULE_PARAMETERS, ALERT_SEVERITY } from '@kbn/rule-data-utils';
import { ICON_TEST_ID } from './kibana_alert_related_integrations_cell_renderer';
import { useGetIntegrationFromPackageName } from '../../../hooks/alert_summary/use_get_integration_from_package_name';
import { BADGE_TEST_ID } from './kibana_alert_severity_cell_renderer';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import { TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID } from './kibana_alert_related_integrations_cell_renderer';
import { INTEGRATION_ICON_TEST_ID } from '../common/integration_icon';
jest.mock('../../../hooks/alert_summary/use_get_integration_from_package_name');
const packages: PackageListItem[] = [
{
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
},
];
describe('CellValue', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle missing field', () => {
const alert: Alert = {
_id: '_id',
@ -29,7 +44,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -46,7 +61,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -63,7 +78,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -80,7 +95,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -97,7 +112,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -114,7 +129,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -131,7 +146,7 @@ describe('CellValue', () => {
const { getByText } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
@ -139,24 +154,22 @@ describe('CellValue', () => {
});
it('should use related integration renderer', () => {
(useGetIntegrationFromPackageName as jest.Mock).mockReturnValue({
integration: {},
isLoading: false,
});
const alert: Alert = {
_id: '_id',
_index: '_index',
[ALERT_RULE_PARAMETERS]: [{ related_integrations: { package: ['splunk'] } }],
};
const columnId = ALERT_RULE_PARAMETERS;
const { getByTestId } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);
expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument();
expect(
getByTestId(`${TABLE_RELATED_INTEGRATION_CELL_RENDERER_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`)
).toBeInTheDocument();
});
it('should use severity renderer', () => {
@ -169,7 +182,7 @@ describe('CellValue', () => {
const { getByTestId } = render(
<TestProviders>
<CellValue alert={alert} columnId={columnId} />
<CellValue alert={alert} columnId={columnId} packages={packages} />
</TestProviders>
);

View file

@ -8,6 +8,7 @@
import React, { memo } from 'react';
import type { Alert } from '@kbn/alerting-types';
import { ALERT_RULE_PARAMETERS, ALERT_SEVERITY } from '@kbn/rule-data-utils';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { BasicCellRenderer } from './basic_cell_renderer';
import { KibanaAlertSeverityCellRenderer } from './kibana_alert_severity_cell_renderer';
import { KibanaAlertRelatedIntegrationsCellRenderer } from './kibana_alert_related_integrations_cell_renderer';
@ -24,6 +25,11 @@ export interface CellValueProps {
* Column id passed from the renderCellValue callback via EuiDataGridProps['renderCellValue'] interface
*/
columnId: string;
/**
* List of installed AI for SOC integrations.
* This comes from the additionalContext property on the table.
*/
packages: PackageListItem[];
}
/**
@ -31,12 +37,12 @@ export interface CellValueProps {
* It renders all the values currently as simply as possible (see code comments below).
* It will be soon improved to support custom renders for specific fields (like kibana.alert.rule.parameters and kibana.alert.severity).
*/
export const CellValue = memo(({ alert, columnId }: CellValueProps) => {
export const CellValue = memo(({ alert, columnId, packages }: CellValueProps) => {
let component;
switch (columnId) {
case ALERT_RULE_PARAMETERS:
component = <KibanaAlertRelatedIntegrationsCellRenderer alert={alert} />;
component = <KibanaAlertRelatedIntegrationsCellRenderer alert={alert} packages={packages} />;
break;
case ALERT_SEVERITY:

View file

@ -11,14 +11,35 @@ import type { DataView } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
import { TestProviders } from '../../../../common/mock';
import { Table } from './table';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
const dataView: DataView = createStubDataView({ spec: {} });
const packages: PackageListItem[] = [
{
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
},
];
const ruleResponse = {
rules: [],
isLoading: false,
};
describe('<Table />', () => {
it('should render all components', () => {
const { getByTestId } = render(
<TestProviders>
<Table dataView={dataView} groupingFilters={[]} />
<Table
dataView={dataView}
groupingFilters={[]}
packages={packages}
ruleResponse={ruleResponse}
/>
</TestProviders>
);

View file

@ -26,6 +26,7 @@ import type {
EuiDataGridStyle,
EuiDataGridToolBarVisibilityOptions,
} from '@elastic/eui';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { ActionsCell } from './actions_cell';
import { AdditionalToolbarControls } from './additional_toolbar_controls';
import { getDataViewStateFromIndexFields } from '../../../../common/containers/source/use_data_view';
@ -36,6 +37,7 @@ import { useKibana } from '../../../../common/lib/kibana';
import { CellValue } from './render_cell';
import { buildTimeRangeFilter } from '../../alerts_table/helpers';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
const TIMESTAMP_COLUMN = i18n.translate(
'xpack.securitySolution.alertSummary.table.column.timeStamp',
@ -84,6 +86,26 @@ const TOOLBAR_VISIBILITY: EuiDataGridToolBarVisibilityOptions = {
};
const GRID_STYLE: EuiDataGridStyle = { border: 'horizontal' };
interface AdditionalTableContext {
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
export interface TableProps {
/**
* DataView created for the alert summary page
@ -93,13 +115,30 @@ export interface TableProps {
* Groups filters passed from the GroupedAlertsTable component via the renderChildComponent callback
*/
groupingFilters: Filter[];
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
/**
* Renders the table showing all the alerts. This component leverages the ResponseOps AlertsTable in a similar way that the alerts page does.
* The table is used in combination with the GroupedAlertsTable component.
*/
export const Table = memo(({ dataView, groupingFilters }: TableProps) => {
export const Table = memo(({ dataView, groupingFilters, packages, ruleResponse }: TableProps) => {
const {
services: {
application,
@ -178,9 +217,18 @@ export const Table = memo(({ dataView, groupingFilters }: TableProps) => {
[dataView]
);
const additionalContext: AdditionalTableContext = useMemo(
() => ({
packages,
ruleResponse,
}),
[packages, ruleResponse]
);
return (
<AlertsTable
actionsColumnWidth={ACTION_COLUMN_WIDTH}
additionalContext={additionalContext}
browserFields={browserFields}
columns={columns}
consumers={ALERT_TABLE_CONSUMERS}

View file

@ -11,14 +11,30 @@ import type { DataView } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
import { TestProviders } from '../../../../common/mock';
import { GROUPED_TABLE_TEST_ID, TableSection } from './table_section';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
const dataView: DataView = createStubDataView({ spec: {} });
const packages: PackageListItem[] = [
{
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
},
];
const ruleResponse = {
rules: [],
isLoading: false,
};
describe('<TableSection />', () => {
it('should render all components', () => {
const { getByTestId } = render(
<TestProviders>
<TableSection dataView={dataView} />
<TableSection dataView={dataView} packages={packages} ruleResponse={ruleResponse} />
</TestProviders>
);

View file

@ -9,6 +9,8 @@ import React, { memo, useCallback, useMemo } from 'react';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
import { TableId } from '@kbn/securitysolution-data-table';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { TableSectionContextProvider } from './table_section_context';
import { groupStatsRenderer } from './group_stats_renderers';
import { groupingOptions } from './grouping_options';
import { groupTitleRenderers } from './group_title_renderers';
@ -20,6 +22,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { GroupedAlertsTable } from '../../alerts_table/alerts_grouping';
import { groupStatsAggregations } from './group_stats_aggregations';
import { useUserData } from '../../user_info';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
export const GROUPED_TABLE_TEST_ID = 'alert-summary-grouped-table';
@ -30,13 +33,30 @@ export interface TableSectionProps {
* DataView created for the alert summary page
*/
dataView: DataView;
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
/**
* Section rendering the table in the alert summary page.
* This component leverages the GroupedAlertsTable and the ResponseOps AlertsTable also used in the alerts page.
*/
export const TableSection = memo(({ dataView }: TableSectionProps) => {
export const TableSection = memo(({ dataView, packages, ruleResponse }: TableSectionProps) => {
const indexNames = useMemo(() => dataView.getIndexPattern(), [dataView]);
const { to, from } = useGlobalTime();
@ -57,29 +77,38 @@ export const TableSection = memo(({ dataView }: TableSectionProps) => {
);
const renderChildComponent = useCallback(
(groupingFilters: Filter[]) => <Table dataView={dataView} groupingFilters={groupingFilters} />,
[dataView]
(groupingFilters: Filter[]) => (
<Table
dataView={dataView}
groupingFilters={groupingFilters}
packages={packages}
ruleResponse={ruleResponse}
/>
),
[dataView, packages, ruleResponse]
);
return (
<div data-test-subj={GROUPED_TABLE_TEST_ID}>
<GroupedAlertsTable
accordionButtonContent={groupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
defaultGroupingOptions={groupingOptions}
from={from}
globalFilters={filters}
globalQuery={globalQuery}
hasIndexMaintenance={hasIndexMaintenance ?? false}
hasIndexWrite={hasIndexWrite ?? false}
loading={false}
renderChildComponent={renderChildComponent}
runtimeMappings={runtimeMappings}
signalIndexName={indexNames}
tableId={TableId.alertsOnAlertSummaryPage}
to={to}
/>
</div>
<TableSectionContextProvider packages={packages} ruleResponse={ruleResponse}>
<div data-test-subj={GROUPED_TABLE_TEST_ID}>
<GroupedAlertsTable
accordionButtonContent={groupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
defaultGroupingOptions={groupingOptions}
from={from}
globalFilters={filters}
globalQuery={globalQuery}
hasIndexMaintenance={hasIndexMaintenance ?? false}
hasIndexWrite={hasIndexWrite ?? false}
loading={false}
renderChildComponent={renderChildComponent}
runtimeMappings={runtimeMappings}
signalIndexName={indexNames}
tableId={TableId.alertsOnAlertSummaryPage}
to={to}
/>
</div>
</TableSectionContextProvider>
);
});

View file

@ -0,0 +1,71 @@
/*
* 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, { createContext, memo, useContext, useMemo } from 'react';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
export interface TableSectionContext {
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
/**
* A context provider for the AI for SOC alert summary table grouping component.
* This allows group stats and renderers to not have to fetch rules and packages.
*/
export const TableSectionContext = createContext<TableSectionContext | undefined>(undefined);
export type TableSectionContextProviderProps = {
/**
* React components to render
*/
children: React.ReactNode;
} & TableSectionContext;
export const TableSectionContextProvider = memo(
({ children, packages, ruleResponse }: TableSectionContextProviderProps) => {
const contextValue = useMemo(
() => ({
packages,
ruleResponse,
}),
[packages, ruleResponse]
);
return (
<TableSectionContext.Provider value={contextValue}>{children}</TableSectionContext.Provider>
);
}
);
TableSectionContextProvider.displayName = 'TableSectionContextProvider';
export const useTableSectionContext = (): TableSectionContext => {
const contextValue = useContext(TableSectionContext);
if (!contextValue) {
throw new Error('TableSectionContext can only be used within TableSectionContext provider');
}
return contextValue;
};

View file

@ -45,6 +45,10 @@ const packages: PackageListItem[] = [
version: '',
},
];
const ruleResponse = {
rules: [],
isLoading: false,
};
describe('<Wrapper />', () => {
it('should render a loading skeleton while creating the dataView', async () => {
@ -61,7 +65,7 @@ describe('<Wrapper />', () => {
});
await act(async () => {
const { getByTestId } = render(<Wrapper packages={packages} />);
const { getByTestId } = render(<Wrapper packages={packages} ruleResponse={ruleResponse} />);
expect(getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(SKELETON_TEST_ID)).toBeInTheDocument();
@ -86,7 +90,7 @@ describe('<Wrapper />', () => {
}));
await act(async () => {
const { getByTestId } = render(<Wrapper packages={packages} />);
const { getByTestId } = render(<Wrapper packages={packages} ruleResponse={ruleResponse} />);
await new Promise(process.nextTick);
@ -123,7 +127,7 @@ describe('<Wrapper />', () => {
await act(async () => {
const { getByTestId } = render(
<TestProviders>
<Wrapper packages={packages} />
<Wrapper packages={packages} ruleResponse={ruleResponse} />
</TestProviders>
);

View file

@ -16,6 +16,7 @@ import {
import { i18n } from '@kbn/i18n';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import type { RuleResponse } from '../../../../common/api/detection_engine';
import { useKibana } from '../../../common/lib/kibana';
import { KPIsSection } from './kpis/kpis_section';
import { IntegrationSection } from './integrations/integration_section';
@ -38,6 +39,19 @@ export interface WrapperProps {
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
/**
@ -46,7 +60,7 @@ export interface WrapperProps {
* Once the dataView is correctly created, we render the content.
* If the creation fails, we show an error message.
*/
export const Wrapper = memo(({ packages }: WrapperProps) => {
export const Wrapper = memo(({ packages, ruleResponse }: WrapperProps) => {
const { data } = useKibana().services;
const [dataView, setDataView] = useState<DataView | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
@ -96,11 +110,15 @@ export const Wrapper = memo(({ packages }: WrapperProps) => {
<div data-test-subj={CONTENT_TEST_ID}>
<IntegrationSection packages={packages} />
<EuiHorizontalRule />
<SearchBarSection dataView={dataView} packages={packages} />
<SearchBarSection
dataView={dataView}
packages={packages}
ruleResponse={ruleResponse}
/>
<EuiSpacer />
<KPIsSection dataView={dataView} />
<EuiSpacer />
<TableSection dataView={dataView} />
<TableSection dataView={dataView} packages={packages} ruleResponse={ruleResponse} />
</div>
)}
</>

View file

@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import type { Alert } from '@kbn/alerting-types';
import { flattenAlertType } from '../../utils/flatten_alert_type';
import { getAlertFieldValueAsStringOrNull } from '../../utils/get_alert_field_value_as_string_or_null';
import { getAlertFieldValueAsStringOrNull } from '../../utils/type_utils';
import {
PROMPT_CONTEXT_ALERT_CATEGORY,
PROMPT_CONTEXTS,

View file

@ -1,51 +0,0 @@
/*
* 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 type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useFetchIntegrations } from './use_fetch_integrations';
export interface UseGetIntegrationFromRuleIdParams {
/**
*
*/
packageName: string | null;
}
export interface UseGetIntegrationFromRuleIdResult {
/**
* List of integrations ready to be consumed by the IntegrationFilterButton component
*/
integration: PackageListItem | undefined;
/**
* True while rules are being fetched
*/
isLoading: boolean;
}
/**
*
*/
export const useGetIntegrationFromPackageName = ({
packageName,
}: UseGetIntegrationFromRuleIdParams): UseGetIntegrationFromRuleIdResult => {
// Fetch all packages
const { installedPackages, isLoading } = useFetchIntegrations();
const integration = useMemo(
() => installedPackages.find((installedPackage) => installedPackage.name === packageName),
[installedPackages, packageName]
);
return useMemo(
() => ({
integration,
isLoading,
}),
[integration, isLoading]
);
};

View file

@ -6,12 +6,10 @@
*/
import { renderHook } from '@testing-library/react';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { useFetchIntegrations } from './use_fetch_integrations';
import { useGetIntegrationFromRuleId } from './use_get_integration_from_rule_id';
jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query');
jest.mock('./use_fetch_integrations');
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import type { RuleResponse } from '../../../../common/api/detection_engine';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
describe('useGetIntegrationFromRuleId', () => {
beforeEach(() => {
@ -19,63 +17,38 @@ describe('useGetIntegrationFromRuleId', () => {
});
it('should return undefined integration when no matching rule is found', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({ data: { rules: [] }, isLoading: false });
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: false,
});
const packages: PackageListItem[] = [];
const ruleId = '';
const rules: RuleResponse[] = [];
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
const { result } = renderHook(() => useGetIntegrationFromRuleId({ packages, ruleId, rules }));
expect(result.current.isLoading).toBe(false);
expect(result.current.integration).toBe(undefined);
});
it('should render loading true is rules are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [{ name: 'rule_name' }],
isLoading: false,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
expect(result.current.isLoading).toBe(true);
expect(result.current.integration).toBe(undefined);
});
it('should render loading true if packages are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: { rules: [] },
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: true,
});
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: '' }));
expect(result.current.isLoading).toBe(true);
expect(result.current.integration).toBe(undefined);
});
it('should render a matching integration', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: { rules: [{ id: 'rule_id', name: 'rule_name' }] },
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [{ name: 'rule_name' }],
isLoading: false,
});
const packages: PackageListItem[] = [
{
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
},
];
const ruleId = 'rule_id';
const rules: RuleResponse[] = [{ id: 'rule_id', name: 'splunk' } as RuleResponse];
const { result } = renderHook(() => useGetIntegrationFromRuleId({ ruleId: 'rule_id' }));
const { result } = renderHook(() => useGetIntegrationFromRuleId({ packages, ruleId, rules }));
expect(result.current.isLoading).toBe(false);
expect(result.current.integration).toEqual({ name: 'rule_name' });
expect(result.current.integration).toEqual({
id: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
name: 'splunk',
status: installationStatuses.NotInstalled,
title: 'Splunk',
version: '0.1.0',
});
});
});

View file

@ -7,15 +7,23 @@
import { useMemo } from 'react';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useFetchIntegrations } from './use_fetch_integrations';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import type { RuleResponse } from '../../../../common/api/detection_engine';
const EMPTY_ARRAY: RuleResponse[] = [];
export interface UseGetIntegrationFromRuleIdParams {
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Id of the rule. This should be the value from the signal.rule.id field
*/
ruleId: string | string[];
/**
* Result from fetching all rules
*/
rules: RuleResponse[] | undefined;
}
export interface UseGetIntegrationFromRuleIdResult {
@ -23,44 +31,33 @@ export interface UseGetIntegrationFromRuleIdResult {
* List of integrations ready to be consumed by the IntegrationFilterButton component
*/
integration: PackageListItem | undefined;
/**
* True while rules are being fetched
*/
isLoading: boolean;
}
/**
* Hook that fetches rule and packages data. It then uses that data to find if there is a package (integration)
* that matches the rule id value passed via prop (value for the signal.rule.id field).
*
* Hook that returns a package (integration) from a ruleId (value for the signal.rule.id field), a list of rules and packages.
* This hook is used in the GroupedAlertTable's accordion when grouping by signal.rule.id, to render the title as well as statistics.
*/
export const useGetIntegrationFromRuleId = ({
packages,
ruleId,
rules = EMPTY_ARRAY,
}: UseGetIntegrationFromRuleIdParams): UseGetIntegrationFromRuleIdResult => {
// Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total)
const { data, isLoading: ruleIsLoading } = useFindRulesQuery({});
// Fetch all packages
const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations();
// From the ruleId (which should be a value for a signal.rule.id field) we find the rule
// of the same id, which we then use its name to match a package's name.
const integration: PackageListItem | undefined = useMemo(() => {
const signalRuleId = Array.isArray(ruleId) ? ruleId[0] : ruleId;
const rule = (data?.rules || []).find((r: RuleResponse) => r.id === signalRuleId);
const rule = rules.find((r: RuleResponse) => r.id === signalRuleId);
if (!rule) {
return undefined;
}
return installedPackages.find((installedPackage) => installedPackage.name === rule.name);
}, [data?.rules, installedPackages, ruleId]);
return packages.find((p) => p.name === rule.name);
}, [packages, rules, ruleId]);
return useMemo(
() => ({
integration,
isLoading: ruleIsLoading || integrationIsLoading,
}),
[integration, integrationIsLoading, ruleIsLoading]
[integration]
);
};

View file

@ -10,11 +10,10 @@ import { useIntegrations } from './use_integrations';
import { useKibana } from '../../../common/lib/kibana';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { FILTER_KEY } from '../../components/alert_summary/search_bar/integrations_filter_button';
import type { RuleResponse } from '../../../../common/api/detection_engine';
jest.mock('../../../common/lib/kibana');
jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query');
describe('useIntegrations', () => {
beforeEach(() => {
@ -33,18 +32,6 @@ describe('useIntegrations', () => {
},
},
});
(useFindRulesQuery as jest.Mock).mockReturnValue({
isLoading: false,
data: {
rules: [
{
related_integrations: [{ package: 'splunk' }],
id: 'SplunkRuleId',
},
],
total: 0,
},
});
const packages: PackageListItem[] = [
{
@ -55,8 +42,17 @@ describe('useIntegrations', () => {
version: '',
},
];
const ruleResponse = {
rules: [
{
related_integrations: [{ package: 'splunk' }],
id: 'SplunkRuleId',
} as RuleResponse,
],
isLoading: false,
};
const { result } = renderHook(() => useIntegrations({ packages }));
const { result } = renderHook(() => useIntegrations({ packages, ruleResponse }));
expect(result.current).toEqual({
isLoading: false,
@ -95,18 +91,6 @@ describe('useIntegrations', () => {
},
},
});
(useFindRulesQuery as jest.Mock).mockReturnValue({
isLoading: false,
data: {
rules: [
{
related_integrations: [{ package: 'splunk' }],
id: 'SplunkRuleId',
},
],
total: 0,
},
});
const packages: PackageListItem[] = [
{
@ -117,8 +101,17 @@ describe('useIntegrations', () => {
version: '',
},
];
const ruleResponse = {
rules: [
{
related_integrations: [{ package: 'splunk' }],
id: 'SplunkRuleId',
} as RuleResponse,
],
isLoading: false,
};
const { result } = renderHook(() => useIntegrations({ packages }));
const { result } = renderHook(() => useIntegrations({ packages, ruleResponse }));
expect(result.current).toEqual({
isLoading: false,
@ -138,10 +131,6 @@ describe('useIntegrations', () => {
data: { query: { filterManager: { getFilters: jest.fn().mockReturnValue([]) } } },
},
});
(useFindRulesQuery as jest.Mock).mockReturnValue({
isLoading: false,
data: undefined,
});
const packages: PackageListItem[] = [
{
@ -152,8 +141,12 @@ describe('useIntegrations', () => {
version: '',
},
];
const ruleResponse = {
rules: [],
isLoading: false,
};
const { result } = renderHook(() => useIntegrations({ packages }));
const { result } = renderHook(() => useIntegrations({ packages, ruleResponse }));
expect(result.current).toEqual({
isLoading: false,
@ -167,10 +160,6 @@ describe('useIntegrations', () => {
data: { query: { filterManager: { getFilters: jest.fn().mockReturnValue([]) } } },
},
});
(useFindRulesQuery as jest.Mock).mockReturnValue({
isLoading: true,
data: undefined,
});
const packages: PackageListItem[] = [
{
@ -181,8 +170,12 @@ describe('useIntegrations', () => {
version: '',
},
];
const ruleResponse = {
rules: [],
isLoading: true,
};
const { result } = renderHook(() => useIntegrations({ packages }));
const { result } = renderHook(() => useIntegrations({ packages, ruleResponse }));
expect(result.current).toEqual({
isLoading: true,

View file

@ -11,7 +11,6 @@ import type {
EuiSelectableOption,
EuiSelectableOptionCheckedType,
} from '@elastic/eui/src/components/selectable/selectable_option';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { filterExistsInFiltersArray } from '../../utils/filter';
import { useKibana } from '../../../common/lib/kibana';
import type { RuleResponse } from '../../../../common/api/detection_engine';
@ -24,6 +23,19 @@ export interface UseIntegrationsParams {
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
/**
* Result from the useQuery to fetch all rules
*/
ruleResponse: {
/**
* Result from fetching all rules
*/
rules: RuleResponse[];
/**
* True while rules are being fetched
*/
isLoading: boolean;
};
}
export interface UseIntegrationsResult {
@ -42,10 +54,10 @@ export interface UseIntegrationsResult {
* If there is no match between a package and the rules, the integration is not returned.
* If a filter exists (we assume that this filter is negated) we do not mark the integration as checked for the EuiFilterButton.
*/
export const useIntegrations = ({ packages }: UseIntegrationsParams): UseIntegrationsResult => {
// Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total)
const { data, isLoading } = useFindRulesQuery({});
export const useIntegrations = ({
packages,
ruleResponse,
}: UseIntegrationsParams): UseIntegrationsResult => {
const {
data: {
query: { filterManager },
@ -59,7 +71,7 @@ export const useIntegrations = ({ packages }: UseIntegrationsParams): UseIntegra
const result: EuiSelectableOption[] = [];
packages.forEach((p: PackageListItem) => {
const matchingRule = (data?.rules || []).find((r: RuleResponse) =>
const matchingRule = ruleResponse.rules.find((r: RuleResponse) =>
r.related_integrations.map((ri) => ri.package).includes(p.name)
);
@ -83,13 +95,13 @@ export const useIntegrations = ({ packages }: UseIntegrationsParams): UseIntegra
});
return result;
}, [currentFilters, data, packages]);
}, [currentFilters, packages, ruleResponse.rules]);
return useMemo(
() => ({
integrations,
isLoading,
isLoading: ruleResponse.isLoading,
}),
[integrations, isLoading]
[integrations, ruleResponse.isLoading]
);
};

View file

@ -13,12 +13,31 @@ import { LANDING_PAGE_PROMPT_TEST_ID } from '../../components/alert_summary/land
import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url';
import { DATA_VIEW_LOADING_PROMPT_TEST_ID } from '../../components/alert_summary/wrapper';
import { useKibana } from '../../../common/lib/kibana';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
jest.mock('../../hooks/alert_summary/use_fetch_integrations');
jest.mock('../../../common/hooks/use_add_integrations_url');
jest.mock('../../../common/lib/kibana');
jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query');
describe('<AlertSummaryPage />', () => {
beforeEach(() => {
jest.clearAllMocks();
(useFindRulesQuery as jest.Mock).mockReturnValue({
isLoading: false,
data: {
rules: [
{
related_integrations: [{ package: 'splunk' }],
id: 'SplunkRuleId',
},
],
total: 0,
},
});
});
it('should render loading logo', () => {
(useFetchIntegrations as jest.Mock).mockReturnValue({
isLoading: true,

View file

@ -6,8 +6,9 @@
*/
import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui';
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { useFetchIntegrations } from '../../hooks/alert_summary/use_fetch_integrations';
import { LandingPage } from '../../components/alert_summary/landing_page/landing_page';
import { Wrapper } from '../../components/alert_summary/wrapper';
@ -21,11 +22,26 @@ const LOADING_INTEGRATIONS = i18n.translate('xpack.securitySolution.alertSummary
/**
* Alert summary page rendering alerts generated by AI for SOC integrations.
* This page should be only rendered for the AI for SOC product line.
* It fetches all the rules and packages (integration) to pass them down to the rest of the page.
*/
export const AlertSummaryPage = memo(() => {
const { availablePackages, installedPackages, isLoading } = useFetchIntegrations();
const {
availablePackages,
installedPackages,
isLoading: integrationIsLoading,
} = useFetchIntegrations();
if (isLoading) {
// Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total)
const { data, isLoading: ruleIsLoading } = useFindRulesQuery({});
const ruleResponse = useMemo(
() => ({
rules: data?.rules || [],
isLoading: ruleIsLoading,
}),
[data, ruleIsLoading]
);
if (integrationIsLoading) {
return (
<EuiEmptyPrompt
data-test-subj={LOADING_INTEGRATIONS_TEST_ID}
@ -39,7 +55,7 @@ export const AlertSummaryPage = memo(() => {
return <LandingPage packages={availablePackages} />;
}
return <Wrapper packages={installedPackages} />;
return <Wrapper packages={installedPackages} ruleResponse={ruleResponse} />;
});
AlertSummaryPage.displayName = 'AlertSummaryPage';

View file

@ -8,7 +8,7 @@
import React, { memo, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { IntegrationIcon } from '../../../detections/components/alert_summary/common/integration_icon';
import { IntegrationIcon } from './integration_icon';
import { DocumentSeverity } from '../../document_details/right/components/severity';
import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data';
import { FlyoutTitle } from '../../shared/components/flyout_title';
@ -83,7 +83,7 @@ export const HeaderTitle = memo(() => {
/>
}
>
<IntegrationIcon ruleId={ruleId} iconSize="l" />
<IntegrationIcon ruleId={ruleId} />
</AlertHeaderBlock>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import React from 'react';
import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { useGetIntegrationFromRuleId } from '../../../detections/hooks/alert_summary/use_get_integration_from_rule_id';
import { usePackageIconType } from '@kbn/fleet-plugin/public/hooks';
import { INTEGRATION_TEST_ID, IntegrationIcon } from './integration_icon';
import {
INTEGRATION_ICON_TEST_ID,
INTEGRATION_LOADING_SKELETON_TEST_ID,
} from '../../../detections/components/alert_summary/common/integration_icon';
jest.mock('../../../detections/hooks/alert_summary/use_fetch_integrations');
jest.mock('../../../detection_engine/rule_management/api/hooks/use_find_rules_query');
jest.mock('../../../detections/hooks/alert_summary/use_get_integration_from_rule_id');
jest.mock('@kbn/fleet-plugin/public/hooks');
const LOADING_SKELETON_TEST_ID = `${INTEGRATION_TEST_ID}-${INTEGRATION_LOADING_SKELETON_TEST_ID}`;
const ICON_TEST_ID = `${INTEGRATION_TEST_ID}-${INTEGRATION_ICON_TEST_ID}`;
describe('IntegrationIcon', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return a single integration icon', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: [],
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: false,
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {
title: 'title',
icons: [{ type: 'type', src: 'src' }],
name: 'name',
version: 'version',
},
});
(usePackageIconType as jest.Mock).mockReturnValue('iconType');
const { getByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon ruleId={'name'} />
</IntlProvider>
);
expect(getByTestId(ICON_TEST_ID)).toBeInTheDocument();
});
it('should return the loading skeleton is rules are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: [],
isLoading: true,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: false,
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {},
});
const { getByTestId, queryByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon ruleId={''} />
</IntlProvider>
);
expect(getByTestId(LOADING_SKELETON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument();
});
it('should return the loading skeleton is integrations are loading', () => {
(useFindRulesQuery as jest.Mock).mockReturnValue({
data: [],
isLoading: false,
});
(useFetchIntegrations as jest.Mock).mockReturnValue({
installedPackages: [],
isLoading: true,
});
(useGetIntegrationFromRuleId as jest.Mock).mockReturnValue({
integration: {},
});
const { getByTestId, queryByTestId } = render(
<IntlProvider locale="en">
<IntegrationIcon ruleId={''} />
</IntlProvider>
);
expect(getByTestId(LOADING_SKELETON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(ICON_TEST_ID)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { IntegrationIcon as Icon } from '../../../detections/components/alert_summary/common/integration_icon';
import { useFetchIntegrations } from '../../../detections/hooks/alert_summary/use_fetch_integrations';
import { useFindRulesQuery } from '../../../detection_engine/rule_management/api/hooks/use_find_rules_query';
import { useGetIntegrationFromRuleId } from '../../../detections/hooks/alert_summary/use_get_integration_from_rule_id';
export const INTEGRATION_TEST_ID = 'alert-summary-flyout';
interface IntegrationIconProps {
/**
* Id of the rule the alert was generated by
*/
ruleId: string;
}
/**
* Renders the icon for the integration that matches the rule id.
* It fetches all the rules and packages (integrations) to find the matching by rule id.
* In AI for SOC, we can retrieve the integration/package that matches a specific rule, via the related_integrations field on the rule.
*/
export const IntegrationIcon = memo(({ ruleId }: IntegrationIconProps) => {
// Fetch all rules. For the AI for SOC effort, there should only be one rule per integration (which means for now 5-6 rules total)
const { data, isLoading: ruleIsLoading } = useFindRulesQuery({});
// Fetch all packages
const { installedPackages, isLoading: integrationIsLoading } = useFetchIntegrations();
const { integration } = useGetIntegrationFromRuleId({
packages: installedPackages,
rules: data?.rules,
ruleId,
});
return (
<Icon
data-test-subj={INTEGRATION_TEST_ID}
iconSize="l"
integration={integration}
isLoading={ruleIsLoading || integrationIsLoading}
/>
);
});
IntegrationIcon.displayName = 'IntegrationIcon';