[AI4DSOC] Add configurations integrations page (#217905)

## Summary

Implements the curated Integrations management page for AI for the SOC:

- Adds the new Integrations page utilizing the `PackageListGrid`
exported fleet component
- Paths of `/configurations/integrations/browse` and
`configurations/integrations/installed` to be consistent with current
fleet pages `/integrations/browse` and `/integrations/installed`
- Updates the `PackageCard` to expose new settings options defaulted to
the existing behavior
- Updates the sidebar link order to match the tabs

<img width="1722" alt="Screenshot 2025-04-14 at 12 00 41 PM"
src="https://github.com/user-attachments/assets/982e01b9-4ceb-4a1e-9cfe-4a44d2f9c8bf"
/>

<img width="1720" alt="Screenshot 2025-04-14 at 12 00 55 PM"
src="https://github.com/user-attachments/assets/401f37fe-791f-4f7c-b31f-f0d6b56f1b46"
/>

<img width="517" alt="Screenshot 2025-04-10 at 3 11 29 PM"
src="https://github.com/user-attachments/assets/f60e6eda-6750-40fb-8611-e73ef5d8fa91"
/>

## How to test

- Add the following to `serverless.security.dev.yml`:
  ```
  xpack.securitySolutionServerless.productTypes:
  [
    { product_line: 'ai_soc', product_tier: 'search_ai_lake' },
  ]
  ```
- Run Kibana serverless for security
- Verify behavior matches the UX mockups:
[figma](https://www.figma.com/design/DYs7j4GQdAhg7aWTLI4R69/AI4DSOC?node-id=2969-143558&p=f&m=dev)

Couple things to note:
- some of the actual logos vary slightly from the figma, but UX has
approved
- if you want to actually install integrations in agentless, be sure to
reference fleet docs for [serverless
](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/fleet/dev_docs/local_setup/developing_kibana_in_serverless.md)
and
[agentless](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/fleet/dev_docs/local_setup/agentless.md).
If you just want to get an idea of what it would look like installed
without doing that, just navigate to the Settings tab on the
integrations overview and install its assets

Relates: https://github.com/elastic/security-team/issues/11789
This commit is contained in:
Kylie Meli 2025-04-16 15:42:57 -04:00 committed by GitHub
parent 31f0f211d9
commit a63876b1a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 745 additions and 26 deletions

View file

@ -68,11 +68,13 @@ export function PackageCard({
titleLineClamp,
descriptionLineClamp,
maxCardHeight,
showDescription = true,
showReleaseBadge = true,
}: PackageCardProps) {
const theme = useEuiTheme();
let releaseBadge: React.ReactNode | null = null;
if (release && release !== 'ga') {
if (release && release !== 'ga' && showReleaseBadge) {
releaseBadge = (
<EuiFlexItem grow={false}>
<EuiSpacer size="xs" />
@ -220,7 +222,7 @@ export function PackageCard({
${getLineClampStyles(titleLineClamp)}
}
min-height: 127px;
min-height: ${showDescription ? '127px' : null};
border-color: ${isQuickstart ? theme.euiTheme.colors.accent : null};
max-height: ${maxCardHeight ? `${maxCardHeight}px` : null};
overflow: ${maxCardHeight ? 'hidden' : null};
@ -230,7 +232,7 @@ export function PackageCard({
layout="horizontal"
title={title || ''}
titleSize="xs"
description={description}
description={showDescription ? description : ''}
hasBorder
icon={
<CardIcon
@ -238,7 +240,7 @@ export function PackageCard({
packageName={name}
integrationName={integration}
version={version}
size="xl"
size={showDescription ? 'xl' : 'xxl'}
/>
}
onClick={onClickProp ?? onCardClick}

View file

@ -58,8 +58,10 @@ export interface IntegrationCardItem {
name: string;
onCardClick?: () => void;
release?: IntegrationCardReleaseLabel;
showDescription?: boolean;
showInstallationStatus?: boolean;
showLabels?: boolean;
showReleaseBadge?: boolean;
title: string;
// Security Solution uses this prop to determine how many lines the card title should be truncated
titleLineClamp?: number;

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { useEnhancedIntegrationCards } from './integrations/use_enhanced_integration_cards';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const RETURN_APP_ID = 'returnAppId';
export const RETURN_PATH = 'returnPath';

View file

@ -0,0 +1,169 @@
/*
* 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 {
applyCategoryBadgeAndStyling,
useEnhancedIntegrationCards,
getCategoryBadgeIfAny,
} from './use_enhanced_integration_cards';
import { IntegrationsFacets } from '../../../../../configurations/constants';
import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { installationStatuses } from '@kbn/fleet-plugin/public';
import { renderHook } from '@testing-library/react';
const mockCard = (name: string, categories?: string[]) =>
({
id: `epr:${name}`,
description: 'description',
icons: [],
title: name,
url: `/app/integrations/detail/${name}-1.0.0/overview`,
integration: '',
name,
version: '1.0.0',
release: 'ga',
categories: categories ?? [],
isUnverified: false,
} as IntegrationCardItem);
describe('applyCategoryBadgeAndStyling', () => {
const mockInt = mockCard('crowdstrike', ['edr_xdr']);
it('should add the correct return path to the URL', () => {
const callerView = IntegrationsFacets.available;
const result = applyCategoryBadgeAndStyling(mockInt, callerView);
const urlParams = new URLSearchParams(result.url.split('?')[1]);
expect(urlParams.get('returnPath')).toBe(`/configurations/integrations/${callerView}`);
});
it('should add the EDR/XDR badge if the category includes edr_xdr', () => {
const cardWithEdrXdr = { ...mockInt, categories: ['edr_xdr'] };
const result = applyCategoryBadgeAndStyling(cardWithEdrXdr, IntegrationsFacets.available);
expect(result.extraLabelsBadges).toHaveLength(1);
});
it('should add the SIEM badge if the category includes siem', () => {
const cardWithSiem = { ...mockInt, categories: ['siem'] };
const result = applyCategoryBadgeAndStyling(cardWithSiem, IntegrationsFacets.available);
expect(result.extraLabelsBadges).toHaveLength(1);
});
it('should not add any badge if the category does not include edr_xdr or siem', () => {
const cardWithOtherCategory = { ...mockInt, categories: ['other'] };
const result = applyCategoryBadgeAndStyling(
cardWithOtherCategory,
IntegrationsFacets.available
);
expect(result.extraLabelsBadges).toHaveLength(0);
});
it('should set showDescription and showReleaseBadge to false', () => {
const result = applyCategoryBadgeAndStyling(mockInt, IntegrationsFacets.available);
expect(result.showDescription).toBe(false);
expect(result.showReleaseBadge).toBe(false);
});
it('should set maxCardHeight to 88', () => {
const result = applyCategoryBadgeAndStyling(mockInt, IntegrationsFacets.available);
expect(result.maxCardHeight).toBe(88);
});
});
describe('useEnhancedIntegrationCards', () => {
const intA = mockCard('crowdstrike', ['edr_xdr']);
const intB = mockCard('google_secops', ['siem']);
const intC = mockCard('microsoft_sentinel', ['siem']);
const intD = mockCard('sentinel_one', ['edr_xdr']);
it('should return sorted available integrations with badges applied', () => {
const mockIntegrationsList = [intA, intB, intC, intD];
const { result } = renderHook(() => useEnhancedIntegrationCards(mockIntegrationsList));
expect(result.current.available).toHaveLength(4);
expect(result.current.available[0].id).toBe('epr:google_secops');
expect(result.current.available[1].id).toBe('epr:microsoft_sentinel');
expect(result.current.available[0].extraLabelsBadges).toHaveLength(1);
});
it('should return sorted installed integrations with badges applied', () => {
const mockIntegrationsList = [
intA,
intB,
{ ...intC, installStatus: installationStatuses.Installed },
intD,
];
const { result } = renderHook(() => useEnhancedIntegrationCards(mockIntegrationsList));
expect(result.current.installed).toHaveLength(1);
expect(result.current.installed[0].id).toBe('epr:microsoft_sentinel');
expect(result.current.installed[0].extraLabelsBadges).toHaveLength(1);
});
it('should handle an empty integrations list', () => {
const { result } = renderHook(() => useEnhancedIntegrationCards([]));
expect(result.current.available).toHaveLength(0);
expect(result.current.installed).toHaveLength(0);
});
it('should correctly apply custom display order', () => {
const mockIntegrationsList = [intA, intB, intC, intD];
const shuffledList = [
mockIntegrationsList[3],
mockIntegrationsList[1],
mockIntegrationsList[0],
mockIntegrationsList[2],
];
const { result } = renderHook(() => useEnhancedIntegrationCards(shuffledList));
expect(result.current.available[0].id).toBe('epr:google_secops');
expect(result.current.available[1].id).toBe('epr:microsoft_sentinel');
expect(result.current.available[2].id).toBe('epr:sentinel_one');
expect(result.current.available[3].id).toBe('epr:crowdstrike');
});
});
describe('getCategoryBadgeIfAny', () => {
it('should return "EDR/XDR" when the categories include "edr_xdr"', () => {
const categories = ['edr_xdr', 'other_category'];
const result = getCategoryBadgeIfAny(categories);
expect(result).toBe('EDR/XDR');
});
it('should return "SIEM" when the categories include "siem"', () => {
const categories = ['siem', 'other_category'];
const result = getCategoryBadgeIfAny(categories);
expect(result).toBe('SIEM');
});
it('should return "EDR/XDR" when both "edr_xdr" and "siem" are present', () => {
const categories = ['edr_xdr', 'siem'];
const result = getCategoryBadgeIfAny(categories);
// "edr_xdr" takes precedence, but we don't realistically expect both to be present
expect(result).toBe('EDR/XDR');
});
it('should return null when neither "edr_xdr" nor "siem" are present', () => {
const categories = ['other_category'];
const result = getCategoryBadgeIfAny(categories);
expect(result).toBeNull();
});
it('should return null when the categories array is empty', () => {
const categories: string[] = [];
const result = getCategoryBadgeIfAny(categories);
expect(result).toBeNull();
});
});

View file

@ -0,0 +1,95 @@
/*
* 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, { useMemo } from 'react';
import { EuiFlexItem, EuiSpacer, EuiBadge } from '@elastic/eui';
import { installationStatuses, type IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation';
import { CONFIGURATIONS_PATH } from '../../../../../../common/constants';
import { IntegrationsFacets } from '../../../../../configurations/constants';
import { RETURN_APP_ID, RETURN_PATH } from './constants';
const FEATURED_INTEGRATION_SORT_ORDER = [
'epr:splunk',
'epr:google_secops',
'epr:microsoft_sentinel',
'epr:sentinel_one',
'epr:crowdstrike',
];
const INTEGRATION_CARD_MAX_HEIGHT_PX = 88;
const addPathParamToUrl = (url: string, path: string) => {
const encodedPath = encodeURIComponent(path);
const paramsString = `${RETURN_APP_ID}=${SECURITY_UI_APP_ID}&${RETURN_PATH}=${encodedPath}`;
if (url.indexOf('?') >= 0) {
return `${url}&${paramsString}`;
}
return `${url}?${paramsString}`;
};
export const getCategoryBadgeIfAny = (categories: string[]): string | null => {
return categories.includes('edr_xdr') ? 'EDR/XDR' : categories.includes('siem') ? 'SIEM' : null;
};
export const applyCategoryBadgeAndStyling = (
card: IntegrationCardItem,
callerView: IntegrationsFacets
): IntegrationCardItem => {
const returnPath = `${CONFIGURATIONS_PATH}/integrations/${callerView}`;
const url = addPathParamToUrl(card.url, returnPath);
const categoryBadge = getCategoryBadgeIfAny(card.categories);
return {
...card,
url,
showDescription: false,
showReleaseBadge: false,
extraLabelsBadges: categoryBadge
? ([
<EuiFlexItem grow={false}>
<EuiSpacer size="xs" />
<span>
<EuiBadge color="hollow">{categoryBadge}</EuiBadge>
</span>
</EuiFlexItem>,
] as React.ReactNode[])
: [],
maxCardHeight: INTEGRATION_CARD_MAX_HEIGHT_PX,
};
};
const applyCustomDisplayOrder = (integrationsList: IntegrationCardItem[]) => {
return integrationsList.sort(
(a, b) =>
FEATURED_INTEGRATION_SORT_ORDER.indexOf(a.id) - FEATURED_INTEGRATION_SORT_ORDER.indexOf(b.id)
);
};
export const useEnhancedIntegrationCards = (
integrationsList: IntegrationCardItem[]
): { available: IntegrationCardItem[]; installed: IntegrationCardItem[] } => {
const sorted = applyCustomDisplayOrder(integrationsList);
const available = useMemo(
() => sorted.map((card) => applyCategoryBadgeAndStyling(card, IntegrationsFacets.available)),
[sorted]
);
const installed = useMemo(
() =>
sorted
.map((card) => applyCategoryBadgeAndStyling(card, IntegrationsFacets.installed))
.filter(
(card) =>
card.installStatus === installationStatuses.Installed ||
card.installStatus === installationStatuses.InstallFailed
),
[sorted]
);
return { available, installed };
};

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
/** Allow list of integrations to be available in the AI4DSOC integrations page */
export const SEARCH_AI_LAKE_ALLOWED_INTEGRATIONS: string[] = [
'crowdstrike',
'google_secops',
'microsoft_sentinel',
'sentinel_one',
'splunk',
];

View file

@ -10,3 +10,8 @@ export enum ConfigurationTabs {
basicRules = 'basic_rules',
aiSettings = 'ai_settings',
}
export enum IntegrationsFacets {
available = 'browse',
installed = 'installed',
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../../../..',
roots: ['<rootDir>/x-pack/solutions/security/plugins/security_solution/public/configurations'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/configurations',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/solutions/security/plugins/security_solution/public/configurations/**/*.{ts,tsx}',
],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};

View file

@ -11,16 +11,16 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { Redirect } from 'react-router-dom';
import { BasicRules } from '../tabs/basic_rules';
import { AiSettings } from '../tabs/ai_settings';
import { Integrations } from '../tabs/integrations';
import { CONFIGURATIONS_PATH } from '../../../common/constants';
import { ConfigurationTabs } from '../constants';
import { LazyConfigurationsIntegrationsHome } from '../tabs/integrations';
export const ConfigurationsRouter = React.memo(() => {
return (
<Routes>
<Route
path={`${CONFIGURATIONS_PATH}/:tab(${ConfigurationTabs.integrations})`}
component={Integrations}
component={LazyConfigurationsIntegrationsHome}
/>
<Route
path={`${CONFIGURATIONS_PATH}/:tab(${ConfigurationTabs.aiSettings})`}

View file

@ -4,8 +4,82 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as React from 'react';
export const Integrations = () => {
return <h1>{'Integrations'}</h1>;
};
import React from 'react';
import { EuiSkeletonLoading } from '@elastic/eui';
import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public';
import { Routes, Route } from '@kbn/shared-ux-router';
import { Redirect } from 'react-router-dom';
import { CONFIGURATIONS_PATH } from '../../../common/constants';
import { SEARCH_AI_LAKE_ALLOWED_INTEGRATIONS } from '../../common/lib/search_ai_lake/integrations';
import { useEnhancedIntegrationCards } from '../../common/lib/search_ai_lake/hooks';
import { ConfigurationTabs, IntegrationsFacets } from '../constants';
import { IntegrationsPage, IntegrationsSkeleton } from './integrations/components';
import { withLazyHook } from '../../common/components/with_lazy_hook';
export interface IntegrationsPageProps {
useAvailablePackages: AvailablePackagesHookType;
}
export const ConfigurationsIntegrationsHome = React.memo<IntegrationsPageProps>(
({ useAvailablePackages }) => {
const { filteredCards, isLoading, searchTerm, setSearchTerm } = useAvailablePackages({
prereleaseIntegrationsEnabled: true,
});
const allowedIntegrations = filteredCards.filter((card) =>
SEARCH_AI_LAKE_ALLOWED_INTEGRATIONS.includes(card.name)
);
const { available, installed } = useEnhancedIntegrationCards(allowedIntegrations);
return (
<EuiSkeletonLoading
isLoading={isLoading}
loadingContent={<IntegrationsSkeleton />}
loadedContent={
<>
<Routes>
<Route
path={`${CONFIGURATIONS_PATH}/${ConfigurationTabs.integrations}/:view(${IntegrationsFacets.available})`}
>
<IntegrationsPage
view={IntegrationsFacets.available}
availableIntegrations={available}
installedIntegrations={installed}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
</Route>
<Route
path={`${CONFIGURATIONS_PATH}/${ConfigurationTabs.integrations}/:view(${IntegrationsFacets.installed})`}
>
<IntegrationsPage
view={IntegrationsFacets.installed}
availableIntegrations={available}
installedIntegrations={installed}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
</Route>
<Route
path={`${CONFIGURATIONS_PATH}/${ConfigurationTabs.integrations}`}
render={() => (
<Redirect
to={`${CONFIGURATIONS_PATH}/${ConfigurationTabs.integrations}/${IntegrationsFacets.available}`}
/>
)}
/>
</Routes>
</>
}
/>
);
}
);
ConfigurationsIntegrationsHome.displayName = 'ConfigurationsIntegrationsHome';
export const LazyConfigurationsIntegrationsHome = withLazyHook(ConfigurationsIntegrationsHome, () =>
import('@kbn/fleet-plugin/public').then((module) => module.AvailablePackagesHook())
);
LazyConfigurationsIntegrationsHome.displayName = 'LazyConfigurationsIntegrationsHome';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const FACETS_MAX_WIDTH_PX = 216;
export const INTEGRATIONS_GRID_MAX_WIDTH_PX = 1200;

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { IntegrationsPage } from './integrations_page';
export { IntegrationsSkeleton } from './integrations_skeleton';

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, { lazy } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/react';
import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { noop } from 'lodash';
import { FACETS_MAX_WIDTH_PX, INTEGRATIONS_GRID_MAX_WIDTH_PX } from './constants';
import { IntegrationViewFacets } from './view_facets';
import { IntegrationsFacets } from '../../../constants';
export const PackageListGrid = lazy(async () => ({
default: await import('@kbn/fleet-plugin/public')
.then((module) => module.PackageList())
.then((pkg) => pkg.PackageListGrid),
}));
export interface IntegrationsGridProps {
view: IntegrationsFacets;
availableIntegrations: IntegrationCardItem[];
installedIntegrations: IntegrationCardItem[];
searchTerm: string;
setSearchTerm: (searchTerm: string) => void;
}
export const IntegrationsPage = React.memo<IntegrationsGridProps>(
({ view, availableIntegrations, installedIntegrations, searchTerm, setSearchTerm }) => {
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem
css={css`
max-width: ${FACETS_MAX_WIDTH_PX}px;
`}
>
<IntegrationViewFacets
allCount={availableIntegrations.length}
installedCount={installedIntegrations.length}
selectedFacet={view}
/>
</EuiFlexItem>
<EuiFlexItem
css={css`
max-width: ${INTEGRATIONS_GRID_MAX_WIDTH_PX}px;
`}
>
<PackageListGrid
calloutTopSpacerSize="m"
categories={[]} // We do not want to show categories and subcategories as the search bar filter
emptyStateStyles={{ paddingTop: '16px' }}
list={
view === IntegrationsFacets.available
? availableIntegrations
: view === IntegrationsFacets.installed
? installedIntegrations
: []
}
scrollElementId={'integrations-scroll-container'}
selectedCategory={'security'}
selectedSubCategory={''}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
setCategory={noop}
setUrlandPushHistory={noop}
setUrlandReplaceHistory={noop}
showCardLabels={true}
showControls={false}
showSearchTools={true}
sortByFeaturedIntegrations={false}
spacer={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
);
}
);
IntegrationsPage.displayName = 'IntegrationsGrid';

View file

@ -0,0 +1,59 @@
/*
* 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 { css } from '@emotion/react';
import { EuiFlexGroup, EuiSkeletonRectangle, EuiSpacer } from '@elastic/eui';
import { FACETS_MAX_WIDTH_PX, INTEGRATIONS_GRID_MAX_WIDTH_PX } from './constants';
const FACET_LOADING_WIDTH = '216px';
const FACET_LOADING_HEIGHT = '20px';
const SEARCH_BAR_LOADING_WIDTH = '872px';
const SEARCH_BAR_LOADING_HEIGHT = '40px';
const CARD_LOADING_WIDTH = '279px';
const CARD_LOADING_HEIGHT = '88px';
export const IntegrationsSkeleton: React.FC = () => (
<>
<EuiFlexGroup>
<EuiFlexGroup
css={css`
max-width: ${FACETS_MAX_WIDTH_PX}px;
`}
direction="column"
gutterSize="none"
>
<EuiSpacer size="l" />
<EuiSkeletonRectangle width={FACET_LOADING_WIDTH} height={FACET_LOADING_HEIGHT} />
<EuiSpacer size="m" />
<EuiSkeletonRectangle width={FACET_LOADING_WIDTH} height={FACET_LOADING_HEIGHT} />
</EuiFlexGroup>
<EuiFlexGroup
css={css`
max-width: ${INTEGRATIONS_GRID_MAX_WIDTH_PX}px;
`}
direction="column"
gutterSize="none"
>
<EuiSpacer size="l" />
<EuiSkeletonRectangle width={SEARCH_BAR_LOADING_WIDTH} height={SEARCH_BAR_LOADING_HEIGHT} />
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m">
<EuiSkeletonRectangle width={CARD_LOADING_WIDTH} height={CARD_LOADING_HEIGHT} />
<EuiSkeletonRectangle width={CARD_LOADING_WIDTH} height={CARD_LOADING_HEIGHT} />
<EuiSkeletonRectangle width={CARD_LOADING_WIDTH} height={CARD_LOADING_HEIGHT} />
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="m">
<EuiSkeletonRectangle width={CARD_LOADING_WIDTH} height={CARD_LOADING_HEIGHT} />
<EuiSkeletonRectangle width={CARD_LOADING_WIDTH} height={CARD_LOADING_HEIGHT} />
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexGroup>
</>
);

View file

@ -0,0 +1,91 @@
/*
* 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, screen, fireEvent } from '@testing-library/react';
import { IntegrationsFacets } from '../../../constants';
import { IntegrationViewFacets, ALL, INSTALLED } from './view_facets';
import { useNavigation } from '../../../../common/lib/kibana';
import { SecurityPageName } from '@kbn/deeplinks-security';
jest.mock('../../../../common/lib/kibana', () => ({
useNavigation: jest.fn(),
}));
describe('IntegrationViewFacets', () => {
const mockNavigateTo = jest.fn();
beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue({
navigateTo: mockNavigateTo,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const defaultProps = {
allCount: 10,
installedCount: 5,
selectedFacet: IntegrationsFacets.available,
};
it('renders the "All integrations" and "Installed integrations" buttons', () => {
render(<IntegrationViewFacets {...defaultProps} />);
expect(screen.getByText(ALL)).toBeInTheDocument();
expect(screen.getByText(INSTALLED)).toBeInTheDocument();
});
it('calls navigateTo with the correct path when "All integrations" is clicked', () => {
render(<IntegrationViewFacets {...defaultProps} />);
const allButton = screen.getByTestId('configurations.integrationsAll');
fireEvent.click(allButton);
expect(mockNavigateTo).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.configurationsIntegrations,
path: 'browse',
});
});
it('calls navigateTo with the correct path when "Installed integrations" is clicked', () => {
render(
<IntegrationViewFacets {...defaultProps} selectedFacet={IntegrationsFacets.installed} />
);
const installedButton = screen.getByTestId('configurations.integrationsInstalled');
fireEvent.click(installedButton);
expect(mockNavigateTo).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.configurationsIntegrations,
path: 'installed',
});
});
it('highlights the correct button based on the selectedFacet prop', () => {
const { rerender } = render(<IntegrationViewFacets {...defaultProps} />);
const allButton = screen.getByText('All integrations');
const installedButton = screen.getByText('Installed integrations');
const selectedFacetClass = 'euiFacetButton__text css-kw0vmr-euiFacetButton__text-isSelected';
// Check if the "All integrations" button is selected
expect(allButton).toHaveClass(selectedFacetClass);
expect(installedButton).not.toHaveClass(selectedFacetClass);
// Rerender with the "Installed integrations" selected
rerender(
<IntegrationViewFacets {...defaultProps} selectedFacet={IntegrationsFacets.installed} />
);
// Check if the "Installed integrations" button is selected
expect(allButton).not.toHaveClass(selectedFacetClass);
expect(installedButton).toHaveClass(selectedFacetClass);
});
});

View file

@ -0,0 +1,75 @@
/*
* 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 { EuiFacetGroup, EuiFacetButton } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SecurityPageName } from '@kbn/deeplinks-security';
import { IntegrationsFacets } from '../../../constants';
import { useNavigation } from '../../../../common/lib/kibana';
export interface Props {
allCount: number;
installedCount: number;
selectedFacet: IntegrationsFacets;
}
export const ALL = i18n.translate('xpack.securitySolution.configurations.integrations.allFacet', {
defaultMessage: 'All integrations',
});
export const INSTALLED = i18n.translate(
'xpack.securitySolution.configurations.integrations.installedFacet',
{
defaultMessage: 'Installed integrations',
}
);
export function IntegrationViewFacets({ allCount, installedCount, selectedFacet }: Props) {
const { navigateTo } = useNavigation();
return (
<EuiFacetGroup>
<EuiFacetButton
css={css`
padding-inline: 0px;
`}
id="integrationsAll"
quantity={allCount}
isSelected={selectedFacet === IntegrationsFacets.available}
data-test-subj={'configurations.integrationsAll'}
onClick={() =>
navigateTo({
deepLinkId: SecurityPageName.configurationsIntegrations,
path: 'browse',
})
}
>
{ALL}
</EuiFacetButton>
<EuiFacetButton
css={css`
padding-inline: 0px;
`}
id="integrationsInstalled"
quantity={installedCount}
isSelected={selectedFacet === IntegrationsFacets.installed}
data-test-subj={'configurations.integrationsInstalled'}
onClick={() =>
navigateTo({
deepLinkId: SecurityPageName.configurationsIntegrations,
path: 'installed',
})
}
>
{INSTALLED}
</EuiFacetButton>
</EuiFacetGroup>
);
}

View file

@ -8,15 +8,7 @@
import { useMemo } from 'react';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses, useGetPackagesQuery } from '@kbn/fleet-plugin/public';
// We hardcode these here for now as we currently do not have any other way to filter out all the unwanted integrations.
const AI_FOR_SOC_INTEGRATIONS = [
'splunk', // doesnt yet exist
'google_secops',
'microsoft_sentinel',
'sentinel_one',
'crowdstrike',
];
import { SEARCH_AI_LAKE_ALLOWED_INTEGRATIONS } from '../../../common/lib/search_ai_lake/integrations';
export interface UseFetchIntegrationsResult {
/**
@ -50,7 +42,10 @@ export const useFetchIntegrations = (): UseFetchIntegrationsResult => {
});
const aiForSOCPackages: PackageListItem[] = useMemo(
() => (allPackages?.items || []).filter((pkg) => AI_FOR_SOC_INTEGRATIONS.includes(pkg.name)),
() =>
(allPackages?.items || []).filter((pkg) =>
SEARCH_AI_LAKE_ALLOWED_INTEGRATIONS.includes(pkg.name)
),
[allPackages]
);
const availablePackages: PackageListItem[] = useMemo(

View file

@ -698,13 +698,13 @@ Object {
Object {
"children": Array [
Object {
"link": "securitySolutionUI:configurations-ai_settings",
"link": "securitySolutionUI:configurations-integrations",
},
Object {
"link": "securitySolutionUI:configurations-basic_rules",
},
Object {
"link": "securitySolutionUI:configurations-integrations",
"link": "securitySolutionUI:configurations-ai_settings",
},
],
"id": "configurations",

View file

@ -73,13 +73,13 @@ export const createAiSocNavigationTree$ = (): Rx.Observable<NavigationTreeDefini
renderAs: 'panelOpener',
children: [
{
link: securityLink(SecurityPageName.configurationsAiSettings),
link: securityLink(SecurityPageName.configurationsIntegrations),
},
{
link: securityLink(SecurityPageName.configurationsBasicRules),
},
{
link: securityLink(SecurityPageName.configurationsIntegrations),
link: securityLink(SecurityPageName.configurationsAiSettings),
},
],
},

View file

@ -22,13 +22,13 @@ export const configurationsLink: NodeDefinition<AppDeepLinkId, string, string> =
renderAs: 'panelOpener',
children: [
{
link: `securitySolutionUI:configurations-ai_settings`,
link: `securitySolutionUI:configurations-integrations`,
},
{
link: `securitySolutionUI:configurations-basic_rules`,
},
{
link: `securitySolutionUI:configurations-integrations`,
link: `securitySolutionUI:configurations-ai_settings`,
},
],
};