[AI4DSOR] Alert summary integrations section (#215266)

## Summary

This PR adds the integration section at the top of the alert summary
page. This section shows the installed AI for SOC integrations and an
`Add integration` button. Clicking on the button navigates to the
fleet's page.
In each integration card, we show the integration name, its logo as well
as the last activity time. This last activity value is retrieve as
follow:
- fetch all dataStreams (see [this api
documentation](https://www.elastic.co/docs/api/doc/kibana/operation/operation-get-fleet-data-streams))
- find all the dataStreams that are related to the installed
integrations (via the `package` property)
- from all the matching dataStreams, take the most recently updated (via
the `last_activity_ms` value

![Screenshot 2025-03-20 at 3 53
35 PM](https://github.com/user-attachments/assets/32d6ac14-e0fc-4428-8dac-8df76e6b06be)


https://github.com/user-attachments/assets/7c67e629-e4d3-4ba2-b756-b9ba81e7a667

## 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`

### Notes

You'll need to either have some AI for SOC integrations installed, or
more easily you can:
- change the `alert_summary.tsx` line `38` from `if
(installedPackages.length === 0) {` to `if (installedPackages.length >
0) {` to force the wrapper component to render
- update `42` of the same `alert_summary.tsx` file from `return <Wrapper
packages={installedPackages} />;` to `return <Wrapper
packages={availablePackages} />;` to be able to see some packages

Also you'll dataStreams if you want to be able to test the last activity
value. Easiest would probably be to mock the call return value following
[the
documentation](https://www.elastic.co/docs/api/doc/kibana/operation/operation-get-fleet-data-streams).

### 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/src/platform/packages/shared/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

https://github.com/elastic/security-team/issues/11955
This commit is contained in:
Philippe Oberti 2025-04-04 01:52:57 +02:00 committed by GitHub
parent 3a63089dc7
commit 59c8d19d95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 512 additions and 0 deletions

View file

@ -122,6 +122,7 @@ export type {
// Models
Agent,
AgentStatus,
DataStream,
FleetServerAgentMetadata,
AgentMetadata,
NewAgentPolicy,

View file

@ -91,6 +91,7 @@ export const AvailablePackagesHook = () => {
);
};
export { useGetDataStreams } from './hooks/use_request/data_stream';
export { useGetPackagesQuery } from './hooks/use_request/epm';
export { useGetSettingsQuery } from './hooks/use_request/settings';
export { useLink } from './hooks/use_link';

View file

@ -0,0 +1,73 @@
/*
* 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 type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import {
IntegrationCard,
LAST_ACTIVITY_LOADING_SKELETON_TEST_ID,
LAST_ACTIVITY_VALUE_TEST_ID,
} from './integration_card';
import { useKibana } from '@kbn/kibana-react-plugin/public';
jest.mock('@kbn/kibana-react-plugin/public');
const dataTestSubj = 'test-id';
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('<IntegrationCard />', () => {
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: { http: { basePath: { prepend: jest.fn() } } },
});
});
it('should render the card with skeleton while loading last activity', () => {
const { getByTestId, queryByTestId } = render(
<IntegrationCard
data-test-subj={dataTestSubj}
integration={integration}
isLoading={true}
lastActivity={undefined}
/>
);
expect(getByTestId(dataTestSubj)).toHaveTextContent('Splunk');
expect(
getByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`)
).toBeInTheDocument();
expect(queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).not.toBeInTheDocument();
});
it('should render the card with last activity value', () => {
const lastActivity = 1735711200000; // Wed Jan 01 2025 00:00:00 GMT-0600 (Central Standard Time)
const { getByTestId, queryByTestId } = render(
<IntegrationCard
data-test-subj={dataTestSubj}
integration={integration}
isLoading={false}
lastActivity={lastActivity}
/>
);
expect(
queryByTestId(`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`)
).not.toBeInTheDocument();
expect(getByTestId(`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`)).toHaveTextContent(
'Last synced: 2025-01-01T06:00:00Z'
);
});
});

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 React, { memo } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSkeletonText,
EuiText,
useEuiTheme,
} from '@elastic/eui';
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 { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
const LAST_SYNCED = i18n.translate(
'xpack.securitySolution.alertSummary.integrations.lastSyncedLabel',
{
defaultMessage: 'Last synced: ',
}
);
const MIN_WIDTH = 200;
export const LAST_ACTIVITY_LOADING_SKELETON_TEST_ID = '-last-activity-loading-skeleton';
export const LAST_ACTIVITY_VALUE_TEST_ID = '-last-activity-value';
export interface IntegrationProps {
/**
* Installed AI for SOC integration
*/
integration: PackageListItem;
/**
* True while retrieving data streams to provide the last activity value
*/
isLoading: boolean;
/**
* Timestamp of the last time the integration synced (via data streams)
*/
lastActivity: number | undefined;
/**
* Data test subject string for testing
*/
['data-test-subj']?: string;
}
/**
* Rendered on the alert summary page. The card displays the icon, name and last sync value.
*/
export const IntegrationCard = memo(
({ 'data-test-subj': dataTestSubj, integration, isLoading, lastActivity }: IntegrationProps) => {
const { euiTheme } = useEuiTheme();
return (
<EuiPanel
css={css`
min-width: ${MIN_WIDTH}px;
`}
data-test-subj={dataTestSubj}
hasBorder={true}
hasShadow={false}
paddingSize="s"
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<CardIcon
icons={integration.icons}
integrationName={integration.title}
packageName={integration.name}
size="xl"
version={integration.version}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiText
css={css`
font-weight: ${euiTheme.font.weight.medium};
`}
size="xs"
>
{integration.title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiSkeletonText
data-test-subj={`${dataTestSubj}${LAST_ACTIVITY_LOADING_SKELETON_TEST_ID}`}
isLoading={isLoading}
lines={1}
size="xs"
>
<EuiText
color="subdued"
data-test-subj={`${dataTestSubj}${LAST_ACTIVITY_VALUE_TEST_ID}`}
size="xs"
>
{LAST_SYNCED}
<FormattedRelativePreferenceDate value={lastActivity} />
</EuiText>
</EuiSkeletonText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
);
IntegrationCard.displayName = 'IntegrationCard';

View file

@ -0,0 +1,84 @@
/*
* 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 type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import {
ADD_INTEGRATIONS_BUTTON_TEST_ID,
CARD_TEST_ID,
IntegrationSection,
} from './integration_section';
import { useAddIntegrationsUrl } from '../../../../common/hooks/use_add_integrations_url';
import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity';
import { useKibana } from '@kbn/kibana-react-plugin/public';
jest.mock('../../../../common/hooks/use_add_integrations_url');
jest.mock('../../../hooks/alert_summary/use_integrations_last_activity');
jest.mock('@kbn/kibana-react-plugin/public');
const packages: PackageListItem[] = [
{
id: 'splunk',
name: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
status: installationStatuses.Installed,
title: 'Splunk',
version: '',
},
{
id: 'google_secops',
name: 'google_secops',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
status: installationStatuses.Installed,
title: 'Google SecOps',
version: '',
},
];
describe('<IntegrationSection />', () => {
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
application: { navigateToApp: jest.fn() },
http: {
basePath: {
prepend: jest.fn().mockReturnValue('/app/integrations/detail/splunk-0.1.0/overview'),
},
},
},
});
});
it('should render a card for each integration ', () => {
(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ onClick: jest.fn() });
(useIntegrationsLastActivity as jest.Mock).mockReturnValue({
isLoading: true,
lastActivities: {},
});
const { getByTestId } = render(<IntegrationSection packages={packages} />);
expect(getByTestId(`${CARD_TEST_ID}splunk`)).toHaveTextContent('Splunk');
expect(getByTestId(`${CARD_TEST_ID}google_secops`)).toHaveTextContent('Google SecOps');
});
it('should navigate to the fleet page when clicking on the add integrations button', () => {
const addIntegration = jest.fn();
(useAddIntegrationsUrl as jest.Mock).mockReturnValue({
onClick: addIntegration,
});
(useIntegrationsLastActivity as jest.Mock).mockReturnValue([]);
const { getByTestId } = render(<IntegrationSection packages={[]} />);
getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID).click();
expect(addIntegration).toHaveBeenCalled();
});
});

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, { memo } from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useIntegrationsLastActivity } from '../../../hooks/alert_summary/use_integrations_last_activity';
import { IntegrationCard } from './integration_card';
import { useAddIntegrationsUrl } from '../../../../common/hooks/use_add_integrations_url';
const ADD_INTEGRATION = i18n.translate(
'xpack.securitySolution.alertSummary.integrations.addIntegrationButtonLabel',
{
defaultMessage: 'Add integration',
}
);
export const CARD_TEST_ID = 'alert-summary-integration-card-';
export const ADD_INTEGRATIONS_BUTTON_TEST_ID = 'alert-summary-add-integrations-button';
export interface IntegrationSectionProps {
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
}
/**
* Section rendered at the top of the alert summary page. It displays all the AI for SOC installed integrations
* and allow the user to add more integrations by clicking on a button that links to a Fleet page.
* Each integration card is also displaying the last time the sync happened (using streams).
*/
export const IntegrationSection = memo(({ packages }: IntegrationSectionProps) => {
const { onClick: addIntegration } = useAddIntegrationsUrl(); // TODO this link might have to be revisited once the integration work is done
const { isLoading, lastActivities } = useIntegrationsLastActivity({ packages });
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m" alignItems="center" wrap>
{packages.map((pkg) => (
<EuiFlexItem grow={false} key={pkg.name}>
<IntegrationCard
data-test-subj={`${CARD_TEST_ID}${pkg.name}`}
integration={pkg}
isLoading={isLoading}
lastActivity={lastActivities[pkg.name]}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj={ADD_INTEGRATIONS_BUTTON_TEST_ID}
iconType="plusInCircle"
onClick={addIntegration}
>
{ADD_INTEGRATION}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
});
IntegrationSection.displayName = 'IntegrationSection';

View file

@ -18,6 +18,9 @@ import {
} from './wrapper';
import { useKibana } from '../../../common/lib/kibana';
import { TestProviders } from '../../../common/mock';
import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url';
import { useIntegrationsLastActivity } from '../../hooks/alert_summary/use_integrations_last_activity';
import { ADD_INTEGRATIONS_BUTTON_TEST_ID } from './integrations/integration_section';
import { SEARCH_BAR_TEST_ID } from './search_bar/search_bar_section';
import { KPIS_SECTION } from './kpis/kpis_section';
@ -26,6 +29,8 @@ jest.mock('../../../common/components/search_bar', () => ({
SiemSearchBar: () => <div data-test-subj={'alert-summary-search-bar'} />,
}));
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/hooks/use_add_integrations_url');
jest.mock('../../hooks/alert_summary/use_integrations_last_activity');
const packages: PackageListItem[] = [
{
@ -47,6 +52,7 @@ describe('<Wrapper />', () => {
clearInstanceCache: jest.fn(),
},
},
http: { basePath: { prepend: jest.fn() } },
},
});
@ -86,6 +92,11 @@ describe('<Wrapper />', () => {
});
it('should render the content if the dataView is created correctly', async () => {
(useAddIntegrationsUrl as jest.Mock).mockReturnValue({ onClick: jest.fn() });
(useIntegrationsLastActivity as jest.Mock).mockReturnValue({
isLoading: true,
lastActivities: {},
});
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
@ -116,6 +127,7 @@ describe('<Wrapper />', () => {
expect(getByTestId(DATA_VIEW_LOADING_PROMPT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(CONTENT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ADD_INTEGRATIONS_BUTTON_TEST_ID)).toBeInTheDocument();
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(KPIS_SECTION)).toBeInTheDocument();
});

View file

@ -18,6 +18,7 @@ import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { useKibana } from '../../../common/lib/kibana';
import { KPIsSection } from './kpis/kpis_section';
import { IntegrationSection } from './integrations/integration_section';
import { SearchBarSection } from './search_bar/search_bar_section';
const DATAVIEW_ERROR = i18n.translate('xpack.securitySolution.alertSummary.dataViewError', {
@ -92,6 +93,8 @@ export const Wrapper = memo(({ packages }: WrapperProps) => {
/>
) : (
<div data-test-subj={CONTENT_TEST_ID}>
<IntegrationSection packages={packages} />
<EuiHorizontalRule />
<SearchBarSection dataView={dataView} packages={packages} />
<EuiSpacer />
<KPIsSection dataView={dataView} />

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react';
import { installationStatuses, useGetDataStreams } from '@kbn/fleet-plugin/public';
import { useIntegrationsLastActivity } from './use_integrations_last_activity';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
jest.mock('@kbn/fleet-plugin/public');
const oldestLastActivity = 1735711200000;
const newestLastActivity = oldestLastActivity + 1000;
const packages: PackageListItem[] = [
{
id: 'splunk',
name: 'splunk',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
status: installationStatuses.Installed,
title: 'Splunk',
version: '',
},
{
id: 'google_secops',
name: 'google_secops',
icons: [{ src: 'icon.svg', path: 'mypath/icon.svg', type: 'image/svg+xml' }],
status: installationStatuses.Installed,
title: 'Google SecOps',
version: '',
},
];
describe('useIntegrationsLastActivity', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return isLoading true', () => {
(useGetDataStreams as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
});
const { result } = renderHook(() => useIntegrationsLastActivity({ packages }));
expect(result.current.isLoading).toBe(true);
expect(result.current.lastActivities).toEqual({});
});
it('should return an object with package name and last sync values', () => {
(useGetDataStreams as jest.Mock).mockReturnValue({
data: {
data_streams: [{ package: 'splunk', last_activity_ms: oldestLastActivity }],
},
isLoading: false,
});
const { result } = renderHook(() => useIntegrationsLastActivity({ packages }));
expect(result.current.isLoading).toBe(false);
expect(result.current.lastActivities.splunk).toBe(oldestLastActivity);
});
it('should return most recent value for integration matching multiple dataStreams', () => {
(useGetDataStreams as jest.Mock).mockReturnValue({
data: {
data_streams: [
{ package: 'splunk', last_activity_ms: oldestLastActivity },
{ package: 'splunk', last_activity_ms: newestLastActivity },
{ package: 'google_secops', last_activity_ms: oldestLastActivity },
],
},
isLoading: false,
});
const { result } = renderHook(() => useIntegrationsLastActivity({ packages }));
expect(result.current.isLoading).toBe(false);
expect(result.current.lastActivities.splunk).toBe(newestLastActivity);
expect(result.current.lastActivities.google_secops).toBe(oldestLastActivity);
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { DataStream, PackageListItem } from '@kbn/fleet-plugin/common';
import { useGetDataStreams } from '@kbn/fleet-plugin/public';
export interface UseIntegrationsLastActivityParams {
/**
* List of installed AI for SOC integrations
*/
packages: PackageListItem[];
}
export interface UseIntegrationsLastActivityResult {
/**
* Is true while the data is loading
*/
isLoading: boolean;
/**
* Object that stores each integration name/last activity values
*/
lastActivities: { [id: string]: number };
}
/**
* Fetches dataStreams, finds all the dataStreams for each integration, takes the value of the latest updated stream.
* Returns an object with the package name as the key and the last time it was synced (using data streams) as the value.
*/
export const useIntegrationsLastActivity = ({
packages,
}: UseIntegrationsLastActivityParams): UseIntegrationsLastActivityResult => {
const { data, isLoading } = useGetDataStreams();
// Find all the matching dataStreams for our packages, take the most recently updated one for each package.
const lastActivities: { [id: string]: number } = useMemo(() => {
const la: { [id: string]: number } = {};
packages.forEach((p: PackageListItem) => {
const dataStreams = (data?.data_streams || []).filter(
(d: DataStream) => d.package === p.name
);
dataStreams.sort((a, b) => b.last_activity_ms - a.last_activity_ms);
const lastActivity = dataStreams.shift();
if (lastActivity) {
la[p.name] = lastActivity.last_activity_ms;
}
});
return la;
}, [data, packages]);
return useMemo(
() => ({
isLoading,
lastActivities,
}),
[isLoading, lastActivities]
);
};