[Security Solution][Detections] Related Integrations & Required Fields Feedback & Fixes (#133050)

## Summary

Addressing the following feedback from https://github.com/elastic/kibana/pull/132847:

- [X] On the Rule Management page package name is used instead of package title when the package has only 1 integration:
- [X] move integrations_popover to `related_integrations` directory
- [X] update useInstalledIntegrations to always return array of installedIntegration
- [X] move useInstalledIntegrations to `related_integrations` directory
- [X] Slight update to copy in Rules Table popover
- [X] Ok to use Rule Details UI within Rules Table popover content
- [X] Sort integrations alphabetically
- [X] Update left padding on version mis-match tooltip
- [X] https://github.com/elastic/kibana/issues/133291
- [X] https://github.com/elastic/kibana/issues/133269
- [X]  Add Kibana Advanced Setting for disabling integrations badge on Rules Table
<p align="center">
  <img width="500" src="https://user-images.githubusercontent.com/2946766/171750790-ffa2e3ef-dd7a-499c-9b08-89bafc06dd50.png" />
</p>

- [ ]  Adds tests
  - [x] `useInstalledIntegrations` hook 
  - [X] relatedIntegrations utils
  - [x] IntegrationDescription
- [ ]  Add loaders where necessary since there can now be API delay
  - May hold off on loaders as transition from no installed integrations -> installed integrations isn't too bad as-is

##### Updated integrations popover content on Rules Table to match Rule Details design
<p align="center">
  <img width="500" src="https://user-images.githubusercontent.com/2946766/172263941-3e948b41-7ef7-4281-8354-57e77ddeb433.png" />
</p>


In discussions with @banderror reviewing the different integration states (uninstalled, installed, enabled, and agents enrolled), we are now capturing the distinction between `Installed` and `Enabled` so that we don't confuse users when a package is installed but the integration isn't enabled/configured. I also added tooltips for clarifying each state and what action the user should perform to ensure compatibility. In collab with @yiyangliu9286 @jethr0null (comments below) -- we've consolidated to a single `Installed: enabled` badge, and updated `Uninstalled` to `Not installed` as well.


##### Tooltips

<details><summary>Not installed</summary>
<p align="center">
  <img width="500" src="https://user-images.githubusercontent.com/2946766/172264210-00064485-2df9-408e-953b-9294f16dedf2.png" />
</p>
</details>





<details><summary>Installed</summary>
<p align="center">
  <img width="500" src="https://user-images.githubusercontent.com/2946766/172263672-67b641cd-5895-464a-8897-f26bd0a61073.png" />
</p>
</details>




<details><summary>Installed: enabled</summary>
<p align="center">
  <img width="500" src="https://user-images.githubusercontent.com/2946766/172263783-563ea48d-c96c-4519-87b4-7076582f5da2.png" />
</p>
</details>



### Checklist

Delete any items that are not applicable to this PR.

- [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [X] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
  - Collaborating with docs teams on this dedicated docs issue: https://github.com/elastic/security-docs/issues/2015
- [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [X] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
This commit is contained in:
Garrett Spong 2022-06-09 13:00:47 -06:00 committed by GitHub
parent e09ff34b0a
commit 7bfcb52901
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1003 additions and 433 deletions

View file

@ -449,6 +449,9 @@ events.
[[securitysolution-threatindices]]`securitySolution:defaultThreatIndex`::
A comma-delimited list of Threat Intelligence indices from which the {security-app} collects indicators.
[[securitysolution-enableCcsWarning]]`securitySolution:enableCcsWarning`:: Enables
privilege check warnings in rules for CCS indices.
[[securitysolution-enablenewsfeed]]`securitySolution:enableNewsFeed`:: Enables
the security news feed on the Security *Overview* page.
@ -464,7 +467,10 @@ The URL from which the security news feed content is retrieved.
The default refresh interval for the Security time filter, in milliseconds.
[[security-solution-rules-table-refresh]]`securitySolution:rulesTableRefresh`::
The default period of time in the Security time filter.
Enables auto refresh on the rules and monitoring tables, in milliseconds.
[[securitySolution-showRelatedIntegrations]]`securitySolution:showRelatedIntegrations`::
Shows related integrations on the rules and monitoring tables.
[[securitysolution-timedefaults]]`securitySolution:timeDefaults`::
The default period of time in the Security time filter.

View file

@ -218,6 +218,10 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[
{ "name": "talosIntelligence.com", "url_template": "https://talosintelligence.com/reputation_center/lookup?search={{ip}}" }
]`;
/** This Kibana Advanced Setting shows related integrations on the Rules Table */
export const SHOW_RELATED_INTEGRATIONS_SETTING =
'securitySolution:showRelatedIntegrations' as const;
/**
* Id for the notifications alerting type
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function

View file

@ -6,6 +6,7 @@
*/
export * from './error_schema';
export * from './get_installed_integrations_response_schema';
export * from './get_rule_execution_events_response';
export * from './import_rules_schema';
export * from './prepackaged_rules_schema';

View file

@ -1,102 +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 { EuiLink } from '@elastic/eui';
import { capitalize } from 'lodash';
import React from 'react';
import semver from 'semver';
import {
InstalledIntegration,
InstalledIntegrationArray,
RelatedIntegration,
RelatedIntegrationArray,
} from '../../../../common/detection_engine/schemas/common';
/**
* Returns an `EuiLink` that will link to a given package/integration/version page within fleet
* TODO: Add `title` to RelatedIntegration so we can accurately display the integration pretty name
*
* @param integration either RelatedIntegration or InstalledIntegration
* @param basePath kbn basepath for composing the fleet URL
*/
export const getIntegrationLink = (
integration: RelatedIntegration | InstalledIntegration,
basePath: string
) => {
let packageName: string;
let integrationName: string | undefined;
let integrationTitle: string;
let version: string | null;
// InstalledIntegration
if ('package_name' in integration) {
packageName = integration.package_name;
integrationName = integration.integration_name;
integrationTitle = integration.integration_title ?? integration.package_name;
version = integration.package_version;
} else {
// RelatedIntegration
packageName = integration.package;
integrationName = integration.integration;
integrationTitle = `${capitalize(integration.package)} ${capitalize(integration.integration)}`;
version = semver.valid(semver.coerce(integration.version));
}
const integrationURL =
version != null
? `${basePath}/app/integrations/detail/${packageName}-${version}/overview${
integrationName ? `?integration=${integrationName}` : ''
}`
: `${basePath}/app/integrations/detail/${packageName}`;
return (
<EuiLink href={integrationURL} target="_blank">
{integrationTitle}
</EuiLink>
);
};
export interface InstalledIntegrationAugmented extends InstalledIntegration {
targetVersion: string;
versionSatisfied: boolean;
}
/**
* Given an array of integrations and an array of installed integrations this will return which
* integrations are `available`/`uninstalled` and which are `installed`, and also augmented with
* `targetVersion` and `versionSatisfied`
* @param integrations
* @param installedIntegrations
*/
export const getInstalledRelatedIntegrations = (
integrations: RelatedIntegrationArray,
installedIntegrations: InstalledIntegrationArray
): {
availableIntegrations: RelatedIntegrationArray;
installedRelatedIntegrations: InstalledIntegrationAugmented[];
} => {
const availableIntegrations: RelatedIntegrationArray = [];
const installedRelatedIntegrations: InstalledIntegrationAugmented[] = [];
integrations.forEach((i: RelatedIntegration) => {
const match = installedIntegrations.find(
(installed) =>
installed.package_name === i.package && installed?.integration_name === i?.integration
);
if (match != null) {
// Version check e.g. fleet match `1.2.3` satisfies rule dependency `~1.2.1`
const versionSatisfied = semver.satisfies(match.package_version, i.version);
installedRelatedIntegrations.push({ ...match, targetVersion: i.version, versionSatisfied });
} else {
availableIntegrations.push(i);
}
});
return {
availableIntegrations,
installedRelatedIntegrations,
};
};

View file

@ -1,145 +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 React, { useState } from 'react';
import {
EuiPopover,
EuiBadge,
EuiPopoverTitle,
EuiFlexGroup,
EuiText,
EuiIconTip,
} from '@elastic/eui';
import styled from 'styled-components';
import { InstalledIntegrationArray } from '../../../../common/detection_engine/schemas/common';
import { useBasePath } from '../../lib/kibana';
import { getInstalledRelatedIntegrations, getIntegrationLink } from './helpers';
import { useInstalledIntegrations } from '../../../detections/containers/detection_engine/rules/use_installed_integrations';
import type { RelatedIntegrationArray } from '../../../../common/detection_engine/schemas/common';
import * as i18n from '../../../detections/pages/detection_engine/rules/translations';
export interface IntegrationsPopoverProps {
integrations: RelatedIntegrationArray;
}
const IntegrationsPopoverWrapper = styled(EuiFlexGroup)`
width: 100%;
`;
const PopoverContentWrapper = styled('div')`
max-height: 400px;
max-width: 368px;
overflow: auto;
line-height: ${({ theme }) => theme.eui.euiLineHeight};
`;
const IntegrationListItem = styled('li')`
list-style-type: disc;
margin-left: 25px;
`;
/**
* Component to render installed and available integrations
* @param integrations - array of integrations to display
*/
const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const { data } = useInstalledIntegrations({ packages: [] });
const basePath = useBasePath();
const allInstalledIntegrations: InstalledIntegrationArray = data?.installed_integrations ?? [];
const { availableIntegrations, installedRelatedIntegrations } = getInstalledRelatedIntegrations(
integrations,
allInstalledIntegrations
);
// TODO: Add loader to installed integrations value
const badgeTitle =
data != null
? `${installedRelatedIntegrations.length}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}`
: `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`;
return (
<IntegrationsPopoverWrapper
alignItems="center"
gutterSize="s"
data-test-subj={'IntegrationsWrapper'}
>
<EuiPopover
ownFocus
data-test-subj={'IntegrationsDisplayPopover'}
button={
<EuiBadge
iconType={'package'}
color="hollow"
data-test-subj={'IntegrationsDisplayPopoverButton'}
onClick={() => setPopoverOpen(!isPopoverOpen)}
onClickAriaLabel={badgeTitle}
>
{badgeTitle}
</EuiBadge>
}
isOpen={isPopoverOpen}
closePopover={() => setPopoverOpen(!isPopoverOpen)}
repositionOnScroll
>
<EuiPopoverTitle data-test-subj={'IntegrationsPopoverTitle'}>
{i18n.INTEGRATIONS_POPOVER_TITLE(integrations.length)}
</EuiPopoverTitle>
<PopoverContentWrapper data-test-subj={'IntegrationsPopoverContentWrapper'}>
{data != null && (
<>
<EuiText size={'s'}>
{i18n.INTEGRATIONS_POPOVER_DESCRIPTION_INSTALLED(
installedRelatedIntegrations.length
)}
</EuiText>
<ul>
{installedRelatedIntegrations.map((integration, index) => (
<IntegrationListItem key={index}>
{getIntegrationLink(integration, basePath)}
{!integration?.versionSatisfied && (
<EuiIconTip
type="alert"
content={i18n.INTEGRATIONS_INSTALLED_VERSION_TOOLTIP(
integration.package_version,
integration.targetVersion
)}
position="right"
/>
)}
</IntegrationListItem>
))}
</ul>
</>
)}
{availableIntegrations.length > 0 && (
<>
<EuiText size={'s'}>
{i18n.INTEGRATIONS_POPOVER_DESCRIPTION_UNINSTALLED(availableIntegrations.length)}
</EuiText>
<ul>
{availableIntegrations.map((integration, index) => (
<IntegrationListItem key={index}>
{getIntegrationLink(integration, basePath)}
</IntegrationListItem>
))}
</ul>
</>
)}
</PopoverContentWrapper>
</EuiPopover>
</IntegrationsPopoverWrapper>
);
};
const MemoizedIntegrationsPopover = React.memo(IntegrationsPopoverComponent);
MemoizedIntegrationsPopover.displayName = 'IntegrationsPopover';
export const IntegrationsPopover =
MemoizedIntegrationsPopover as typeof IntegrationsPopoverComponent;

View file

@ -20,6 +20,8 @@ import {
} from '@elastic/eui';
import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils';
import { castEsToKbnFieldTypeName } from '@kbn/field-types';
import { isEmpty } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
@ -556,7 +558,7 @@ export const buildRequiredFieldsDescription = (
label: string,
requiredFields: RequiredFieldArray
): ListItems[] => {
if (requiredFields == null) {
if (isEmpty(requiredFields)) {
return [];
}
@ -569,7 +571,11 @@ export const buildRequiredFieldsDescription = (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize={'xs'}>
<EuiFlexItem grow={false}>
<FieldIcon data-test-subj="field-type-icon" type={rF.type} />
<FieldIcon
data-test-subj="field-type-icon"
type={castEsToKbnFieldTypeName(rF.type)}
label={rF.type}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldTypeText grow={false} size={'s'}>

View file

@ -13,7 +13,7 @@ import styled from 'styled-components';
import { ThreatMapping, Threats, Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { DataViewBase, Filter, FilterStateStore } from '@kbn/es-query';
import { FilterManager } from '@kbn/data-plugin/public';
import { buildRelatedIntegrationsDescription } from './required_integrations_description';
import { buildRelatedIntegrationsDescription } from '../related_integrations/integrations_description';
import type {
RelatedIntegrationArray,
RequiredFieldArray,

View file

@ -1,96 +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 React from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiIconTip } from '@elastic/eui';
import { INTEGRATIONS_INSTALLED_VERSION_TOOLTIP } from '../../../pages/detection_engine/rules/translations';
import { useInstalledIntegrations } from '../../../containers/detection_engine/rules/use_installed_integrations';
import {
getInstalledRelatedIntegrations,
getIntegrationLink,
} from '../../../../common/components/integrations_popover/helpers';
import {
InstalledIntegrationArray,
RelatedIntegration,
RelatedIntegrationArray,
} from '../../../../../common/detection_engine/schemas/common';
import { useBasePath } from '../../../../common/lib/kibana';
import { ListItems } from './types';
import * as i18n from './translations';
const Wrapper = styled.div`
overflow: hidden;
`;
const IntegrationDescriptionComponent: React.FC<{ integration: RelatedIntegration }> = ({
integration,
}) => {
const basePath = useBasePath();
const badgeInstalledColor = '#E0E5EE';
const badgeUninstalledColor = 'accent';
const { data } = useInstalledIntegrations({ packages: [] });
const allInstalledIntegrations: InstalledIntegrationArray = data?.installed_integrations ?? [];
const { availableIntegrations, installedRelatedIntegrations } = getInstalledRelatedIntegrations(
[integration],
allInstalledIntegrations
);
if (availableIntegrations.length > 0) {
return (
<Wrapper>
{getIntegrationLink(integration, basePath)}{' '}
{data != null && (
<EuiBadge color={badgeUninstalledColor}>{i18n.RELATED_INTEGRATIONS_UNINSTALLED}</EuiBadge>
)}
</Wrapper>
);
} else if (installedRelatedIntegrations.length > 0) {
return (
<Wrapper>
{getIntegrationLink(integration, basePath)}{' '}
<EuiBadge color={badgeInstalledColor}>{i18n.RELATED_INTEGRATIONS_INSTALLED}</EuiBadge>
{!installedRelatedIntegrations[0]?.versionSatisfied && (
<EuiIconTip
type="alert"
content={INTEGRATIONS_INSTALLED_VERSION_TOOLTIP(
installedRelatedIntegrations[0]?.package_version,
installedRelatedIntegrations[0]?.targetVersion
)}
position="right"
/>
)}
</Wrapper>
);
} else {
return <></>;
}
};
export const IntegrationDescription = React.memo(IntegrationDescriptionComponent);
const RelatedIntegrationsDescription: React.FC<{ integrations: RelatedIntegrationArray }> = ({
integrations,
}) => (
<>
{integrations?.map((integration, index) => (
<IntegrationDescription key={`${integration.package}-${index}`} integration={integration} />
))}
</>
);
export const buildRelatedIntegrationsDescription = (
label: string,
relatedIntegrations: RelatedIntegrationArray
): ListItems[] => [
{
title: label,
description: <RelatedIntegrationsDescription integrations={relatedIntegrations} />,
},
];

View file

@ -118,17 +118,3 @@ export const EQL_TIMESTAMP_FIELD_LABEL = i18n.translate(
defaultMessage: 'Timestamp field',
}
);
export const RELATED_INTEGRATIONS_INSTALLED = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrationsInstalledDescription',
{
defaultMessage: 'Installed',
}
);
export const RELATED_INTEGRATIONS_UNINSTALLED = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrationsUninstalledDescription',
{
defaultMessage: 'Uninstalled',
}
);

View file

@ -0,0 +1,37 @@
/*
* 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 { installedIntegrationsBase, relatedIntegrations } from '../mock';
import { useInstalledIntegrations } from '../use_installed_integrations';
import { getInstalledRelatedIntegrations } from '../utils';
import { IntegrationDescription } from '.';
import { render, screen } from '@testing-library/react';
jest.mock('../../../../../common/lib/kibana');
jest.mock('../use_installed_integrations');
const mockUseInstalledIntegrations = useInstalledIntegrations as jest.Mock;
mockUseInstalledIntegrations.mockReturnValue({
data: installedIntegrationsBase,
isLoading: false,
isFetching: false,
});
describe('IntegrationDescription', () => {
test('Shows total events returned', () => {
const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] });
const integrationDetails = getInstalledRelatedIntegrations(
relatedIntegrations,
allInstalledIntegrations
);
render(<IntegrationDescription integration={integrationDetails[0]} />);
expect(screen.getByTestId('integrationLink')).toHaveTextContent('Aws Cloudtrail');
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 styled from 'styled-components';
import { EuiBadge, EuiIconTip, EuiToolTip } from '@elastic/eui';
import { useInstalledIntegrations } from '../use_installed_integrations';
import { getInstalledRelatedIntegrations, getIntegrationLink, IntegrationDetails } from '../utils';
import { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common';
import { useBasePath } from '../../../../../common/lib/kibana';
import { ListItems } from '../../description_step/types';
import * as i18n from '../translations';
const Wrapper = styled.div`
overflow: hidden;
`;
const PaddedBadge = styled(EuiBadge)`
margin-left: 5px;
`;
const VersionWarningIconContainer = styled.span`
margin-left: 5px;
`;
export const IntegrationDescriptionComponent: React.FC<{ integration: IntegrationDetails }> = ({
integration,
}) => {
const basePath = useBasePath();
const badgeInstalledColor = 'success';
const badgeUninstalledColor = '#E0E5EE';
const badgeColor = integration.is_installed ? badgeInstalledColor : badgeUninstalledColor;
const badgeTooltip = integration.is_installed
? integration.is_enabled
? i18n.INTEGRATIONS_ENABLED_TOOLTIP
: i18n.INTEGRATIONS_INSTALLED_TOOLTIP
: i18n.INTEGRATIONS_UNINSTALLED_TOOLTIP;
const badgeText = integration.is_installed
? integration.is_enabled
? i18n.INTEGRATIONS_ENABLED
: i18n.INTEGRATIONS_INSTALLED
: i18n.INTEGRATIONS_UNINSTALLED;
return (
<Wrapper>
{getIntegrationLink(integration, basePath)}{' '}
<EuiToolTip content={badgeTooltip}>
<PaddedBadge color={badgeColor}>{badgeText}</PaddedBadge>
</EuiToolTip>
{integration.is_installed && !integration.version_satisfied && (
<VersionWarningIconContainer>
<EuiIconTip
type={'alert'}
color={'warning'}
content={i18n.INTEGRATIONS_INSTALLED_VERSION_TOOLTIP(
integration.package_version,
integration.target_version
)}
/>
</VersionWarningIconContainer>
)}
</Wrapper>
);
};
export const IntegrationDescription = React.memo(IntegrationDescriptionComponent);
export const RelatedIntegrationsDescription: React.FC<{
integrations: RelatedIntegrationArray;
}> = ({ integrations }) => {
const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] });
const integrationDetails = getInstalledRelatedIntegrations(
integrations,
allInstalledIntegrations
);
return (
<>
{integrationDetails.map((integration, index) => (
<IntegrationDescription
key={`${integration.package_name}-${index}`}
integration={integration}
/>
))}
</>
);
};
export const buildRelatedIntegrationsDescription = (
label: string,
relatedIntegrations: RelatedIntegrationArray | undefined
): ListItems[] => {
if (relatedIntegrations == null || relatedIntegrations.length === 0) {
return [];
}
return [
{
title: label,
description: <RelatedIntegrationsDescription integrations={relatedIntegrations} />,
},
];
};

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiPopover,
EuiBadge,
EuiPopoverTitle,
EuiFlexGroup,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import styled from 'styled-components';
import { IntegrationDescription } from '../integrations_description';
import { getInstalledRelatedIntegrations } from '../utils';
import { useInstalledIntegrations } from '../use_installed_integrations';
import type { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common';
import * as i18n from '../translations';
export interface IntegrationsPopoverProps {
integrations: RelatedIntegrationArray;
}
const IntegrationsPopoverWrapper = styled(EuiFlexGroup)`
width: 100%;
`;
const PopoverTitleWrapper = styled(EuiPopoverTitle)`
max-width: 390px;
`;
const PopoverContentWrapper = styled('div')`
max-height: 400px;
max-width: 390px;
overflow: auto;
line-height: ${({ theme }) => theme.eui.euiLineHeight};
`;
const IntegrationListItem = styled('li')`
list-style-type: disc;
margin-left: 25px;
margin-bottom: 5px;
`;
/**
* Component to render installed and available integrations
* @param integrations - array of integrations to display
*/
const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] });
const integrationDetails = getInstalledRelatedIntegrations(
integrations,
allInstalledIntegrations
);
const totalRelatedIntegrationsInstalled = integrationDetails.filter((i) => i.is_enabled).length;
const badgeTitle =
allInstalledIntegrations != null
? `${totalRelatedIntegrationsInstalled}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}`
: `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`;
return (
<IntegrationsPopoverWrapper
alignItems="center"
gutterSize="s"
data-test-subj={'IntegrationsWrapper'}
>
<EuiPopover
ownFocus
data-test-subj={'IntegrationsDisplayPopover'}
button={
<EuiBadge
iconType={'package'}
color="hollow"
data-test-subj={'IntegrationsDisplayPopoverButton'}
onClick={() => setPopoverOpen(!isPopoverOpen)}
onClickAriaLabel={badgeTitle}
>
{badgeTitle}
</EuiBadge>
}
isOpen={isPopoverOpen}
closePopover={() => setPopoverOpen(!isPopoverOpen)}
repositionOnScroll
>
<PopoverTitleWrapper data-test-subj={'IntegrationsPopoverTitle'}>
{i18n.INTEGRATIONS_POPOVER_TITLE(integrations.length)}
</PopoverTitleWrapper>
<PopoverContentWrapper data-test-subj={'IntegrationsPopoverContentWrapper'}>
<EuiText size={'s'}>{i18n.INTEGRATIONS_POPOVER_DESCRIPTION(integrations.length)}</EuiText>
<EuiSpacer size={'s'} />
<ul>
{integrationDetails.map((integration, index) => (
<IntegrationListItem key={`${integration.package_name}-${index}`}>
<IntegrationDescription integration={integration} />
</IntegrationListItem>
))}
</ul>
</PopoverContentWrapper>
</EuiPopover>
</IntegrationsPopoverWrapper>
);
};
const MemoizedIntegrationsPopover = React.memo(IntegrationsPopoverComponent);
MemoizedIntegrationsPopover.displayName = 'IntegrationsPopover';
export const IntegrationsPopover =
MemoizedIntegrationsPopover as typeof IntegrationsPopoverComponent;

View file

@ -0,0 +1,100 @@
/*
* 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 { IntegrationDetails } from './utils';
import {
InstalledIntegrationArray,
RelatedIntegrationArray,
} from '../../../../../common/detection_engine/schemas/common';
export const relatedIntegrations: RelatedIntegrationArray = [
{
package: 'system',
version: '1.6.4',
},
{
package: 'aws',
integration: 'cloudtrail',
version: '~1.11.0',
},
];
export const installedIntegrationsBase: InstalledIntegrationArray = [
{ package_name: 'system', package_title: 'System', package_version: '1.6.4', is_enabled: true },
];
export const installedIntegrationsAWSCloudwatch: InstalledIntegrationArray = [
{
package_name: 'aws',
package_title: 'AWS',
package_version: '1.11.0',
integration_name: 'billing',
integration_title: 'AWS Billing Metrics',
is_enabled: false,
},
{
package_name: 'aws',
package_title: 'AWS',
package_version: '1.11.0',
integration_name: 'cloudtrail',
integration_title: 'AWS Cloudtrail Logs',
is_enabled: false,
},
{
package_name: 'aws',
package_title: 'AWS',
package_version: '1.11.0',
integration_name: 'cloudwatch',
integration_title: 'AWS CloudWatch',
is_enabled: true,
},
{ package_name: 'system', package_title: 'System', package_version: '1.6.4', is_enabled: true },
{
package_name: 'atlassian_bitbucket',
package_title: 'Atlassian Bitbucket',
package_version: '1.0.1',
integration_name: 'audit',
integration_title: 'Audit Logs',
is_enabled: true,
},
];
export const integrationDetailsUninstalled: IntegrationDetails = {
package_name: 'test',
package_title: 'Test',
package_version: '1.2.3',
integration_name: 'integration',
integration_title: 'Integration',
is_enabled: false,
is_installed: false,
target_version: '1.2.3',
version_satisfied: false,
};
export const integrationDetailsInstalled: IntegrationDetails = {
package_name: 'test',
package_title: 'Test',
package_version: '1.2.3',
integration_name: 'integration',
integration_title: 'Integration',
is_enabled: false,
is_installed: true,
target_version: '1.2.3',
version_satisfied: true,
};
export const integrationDetailsEnabled: IntegrationDetails = {
package_name: 'test',
package_title: 'Test',
package_version: '1.1.3',
integration_name: 'integration',
integration_title: 'Integration',
is_enabled: true,
is_installed: true,
target_version: '1.3.3',
version_satisfied: false,
};

View file

@ -0,0 +1,94 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const INTEGRATIONS_INSTALLED = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle',
{
defaultMessage: 'Installed',
}
);
export const INTEGRATIONS_INSTALLED_TOOLTIP = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.installedTooltip',
{
defaultMessage:
'Integration is installed. Configure an integration policy and ensure Elastic Agents are assigned this policy to ingest compatible events.',
}
);
export const INTEGRATIONS_UNINSTALLED = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTitle',
{
defaultMessage: 'Not installed',
}
);
export const INTEGRATIONS_UNINSTALLED_TOOLTIP = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTooltip',
{
defaultMessage:
'Integration is not installed. Follow the integration link to install and configure the integration.',
}
);
export const INTEGRATIONS_ENABLED = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle',
{
defaultMessage: 'Installed: enabled',
}
);
export const INTEGRATIONS_ENABLED_TOOLTIP = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTooltip',
{
defaultMessage:
'Integration is installed and an integration policy with the required configuration exists. Ensure Elastic Agents are assigned this policy to ingest compatible events.',
}
);
export const INTEGRATIONS_BADGE = i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.badgeTitle',
{
defaultMessage: 'integrations',
}
);
export const INTEGRATIONS_POPOVER_TITLE = (integrationsCount: number) =>
i18n.translate('xpack.securitySolution.detectionEngine.relatedIntegrations.popoverTitle', {
values: { integrationsCount },
defaultMessage:
'[{integrationsCount}] Related {integrationsCount, plural, =1 {integration} other {integrations}} available',
});
export const INTEGRATIONS_POPOVER_DESCRIPTION = (integrationsCount: number) =>
i18n.translate('xpack.securitySolution.detectionEngine.relatedIntegrations.popoverDescription', {
values: { integrationsCount },
defaultMessage:
'Install and configure {integrationsCount, plural, =1 {the below integration} other {one or more of the below integrations}} to ingest the necessary data for this detection rule:',
});
export const INTEGRATIONS_INSTALLED_VERSION_TOOLTIP = (
installedVersion: string,
targetVersion: string
) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.relatedIntegrations.popoverDescriptionInstalledVersionTooltip',
{
values: { installedVersion, targetVersion },
defaultMessage:
'Version mismatch -- please resolve! Installed version `{installedVersion}` when target version `{targetVersion}`',
}
);
export const INTEGRATIONS_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.relatedIntegrations.fetchFailDescription',
{
defaultMessage: 'Failed to fetch installed integrations',
}
);

View file

@ -0,0 +1,128 @@
/*
* 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.
*/
jest.mock('../../../containers/detection_engine/rules/api');
jest.mock('../../../../common/lib/kibana');
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, cleanup } from '@testing-library/react-hooks';
import { useInstalledIntegrations } from './use_installed_integrations';
import * as api from '../../../containers/detection_engine/rules/api';
import { useToasts } from '../../../../common/lib/kibana';
describe('useInstalledIntegrations', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(async () => {
cleanup();
});
const createReactQueryWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Turn retries off, otherwise we won't be able to test errors
retry: false,
},
},
});
const wrapper: React.FC = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const render = () =>
renderHook(
() =>
useInstalledIntegrations({
packages: [],
}),
{
wrapper: createReactQueryWrapper(),
}
);
it('calls the API via fetchInstalledIntegrations', async () => {
const fetchInstalledIntegrations = jest.spyOn(api, 'fetchInstalledIntegrations');
const { waitForNextUpdate } = render();
await waitForNextUpdate();
expect(fetchInstalledIntegrations).toHaveBeenCalledTimes(1);
expect(fetchInstalledIntegrations).toHaveBeenLastCalledWith(
expect.objectContaining({ packages: [] })
);
});
it('fetches data from the API', async () => {
const { result, waitForNextUpdate } = render();
// It starts from a loading state
expect(result.current.isLoading).toEqual(true);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(false);
// When fetchRuleExecutionEvents returns
await waitForNextUpdate();
// It switches to a success state
expect(result.current.isLoading).toEqual(false);
expect(result.current.isSuccess).toEqual(true);
expect(result.current.isError).toEqual(false);
expect(result.current.data).toEqual([
{
integration_name: 'audit',
integration_title: 'Audit Logs',
is_enabled: true,
package_name: 'atlassian_bitbucket',
package_title: 'Atlassian Bitbucket',
package_version: '1.0.1',
},
{
is_enabled: true,
package_name: 'system',
package_title: 'System',
package_version: '1.6.4',
},
]);
});
// Skipping until we re-enable errors
it.skip('handles exceptions from the API', async () => {
const exception = new Error('Boom!');
jest.spyOn(api, 'fetchInstalledIntegrations').mockRejectedValue(exception);
const { result, waitForNextUpdate } = render();
// It starts from a loading state
expect(result.current.isLoading).toEqual(true);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(false);
// When fetchRuleExecutionEvents throws
await waitForNextUpdate();
// It switches to an error state
expect(result.current.isLoading).toEqual(false);
expect(result.current.isSuccess).toEqual(false);
expect(result.current.isError).toEqual(true);
expect(result.current.error).toEqual(exception);
// And shows a toast with the caught exception
expect(useToasts().addError).toHaveBeenCalledTimes(1);
expect(useToasts().addError).toHaveBeenCalledWith(exception, {
title: 'Failed to fetch installed integrations',
});
});
});

View file

@ -6,19 +6,19 @@
*/
import { useQuery } from 'react-query';
import { GetInstalledIntegrationsResponse } from '../../../../../common/detection_engine/schemas/response/get_installed_integrations_response_schema';
import { fetchInstalledIntegrations } from './api';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import * as i18n from './translations';
import { InstalledIntegrationArray } from '../../../../../common/detection_engine/schemas/common';
import { fetchInstalledIntegrations } from '../../../containers/detection_engine/rules/api';
// import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
// import * as i18n from './translations';
export interface UseInstalledIntegrationsArgs {
packages?: string[];
}
export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsArgs) => {
const { addError } = useAppToasts();
// const { addError } = useAppToasts();
return useQuery<GetInstalledIntegrationsResponse>(
return useQuery<InstalledIntegrationArray>(
[
'installedIntegrations',
{
@ -26,15 +26,17 @@ export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsA
},
],
async ({ signal }) => {
return fetchInstalledIntegrations({
const integrations = await fetchInstalledIntegrations({
packages,
signal,
});
return integrations.installed_integrations ?? [];
},
{
keepPreviousData: true,
onError: (e) => {
addError(e, { title: i18n.INSTALLED_INTEGRATIONS_FETCH_FAILURE });
// Suppressing for now to prevent excessive errors when fleet isn't configured
// addError(e, { title: i18n.INTEGRATIONS_FETCH_FAILURE });
},
}
);

View file

@ -0,0 +1,212 @@
/*
* 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 {
integrationDetailsEnabled,
integrationDetailsInstalled,
integrationDetailsUninstalled,
} from './mock';
import { render } from '@testing-library/react';
import { getInstalledRelatedIntegrations, getIntegrationLink } from './utils';
describe('Related Integrations Utilities', () => {
describe('#getIntegrationLink', () => {
describe('it returns a correctly formatted integrations link', () => {
test('given an uninstalled integrationDetails', () => {
const link = getIntegrationLink(integrationDetailsUninstalled, 'http://localhost');
const { container } = render(link);
expect(container.firstChild).toHaveProperty(
'href',
'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration'
);
});
test('given an installed integrationDetails', () => {
const link = getIntegrationLink(integrationDetailsInstalled, 'http://localhost');
const { container } = render(link);
expect(container.firstChild).toHaveProperty(
'href',
'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration'
);
});
test('given an enabled integrationDetails with an unsatisfied version', () => {
const link = getIntegrationLink(integrationDetailsEnabled, 'http://localhost');
const { container } = render(link);
expect(container.firstChild).toHaveProperty(
'href',
'http://localhost/app/integrations/detail/test-1.3.3/overview?integration=integration'
);
});
});
});
describe('#getInstalledRelatedIntegrations', () => {
test('it returns a the correct integrationDetails', () => {
const integrationDetails = getInstalledRelatedIntegrations([], []);
expect(integrationDetails.length).toEqual(0);
});
describe('version is correctly computed', () => {
test('Unknown integration that does not exist', () => {
const integrationDetails = getInstalledRelatedIntegrations(
[
{
package: 'foo1',
version: '~1.2.3',
},
{
package: 'foo2',
version: '^1.2.3',
},
{
package: 'foo3',
version: '1.2.x',
},
],
[]
);
expect(integrationDetails[0].target_version).toEqual('1.2.3');
expect(integrationDetails[1].target_version).toEqual('1.2.3');
expect(integrationDetails[2].target_version).toEqual('1.2.0');
});
test('Integration that is not installed', () => {
const integrationDetails = getInstalledRelatedIntegrations(
[
{
package: 'aws',
integration: 'route53',
version: '~1.2.3',
},
{
package: 'system',
version: '^1.2.3',
},
],
[]
);
expect(integrationDetails[0].target_version).toEqual('1.2.3');
expect(integrationDetails[1].target_version).toEqual('1.2.3');
});
test('Integration that is installed, and its version matches required version', () => {
const integrationDetails = getInstalledRelatedIntegrations(
[
{
package: 'aws',
integration: 'route53',
version: '^1.2.3',
},
{
package: 'system',
version: '~1.2.3',
},
],
[
{
package_name: 'aws',
package_title: 'AWS',
package_version: '1.3.0',
integration_name: 'route53',
integration_title: 'AWS Route 53',
is_enabled: false,
},
{
package_name: 'system',
package_title: 'System',
package_version: '1.2.5',
is_enabled: true,
},
]
);
// Since version is satisfied, we check `package_version`
expect(integrationDetails[0].version_satisfied).toEqual(true);
expect(integrationDetails[0].package_version).toEqual('1.3.0');
expect(integrationDetails[1].version_satisfied).toEqual(true);
expect(integrationDetails[1].package_version).toEqual('1.2.5');
});
test('Integration that is installed, and its version is less than required version', () => {
const integrationDetails = getInstalledRelatedIntegrations(
[
{
package: 'aws',
integration: 'route53',
version: '~1.2.3',
},
{
package: 'system',
version: '^1.2.3',
},
],
[
{
package_name: 'aws',
package_title: 'AWS',
package_version: '1.2.0',
integration_name: 'route53',
integration_title: 'AWS Route 53',
is_enabled: false,
},
{
package_name: 'system',
package_title: 'System',
package_version: '1.2.2',
is_enabled: true,
},
]
);
expect(integrationDetails[0].target_version).toEqual('1.2.3');
expect(integrationDetails[1].target_version).toEqual('1.2.3');
});
test('Integration that is installed, and its version is greater than required version', () => {
const integrationDetails = getInstalledRelatedIntegrations(
[
{
package: 'aws',
integration: 'route53',
version: '^1.2.3',
},
{
package: 'system',
version: '~1.2.3',
},
],
[
{
package_name: 'aws',
package_title: 'AWS',
package_version: '2.0.1',
integration_name: 'route53',
integration_title: 'AWS Route 53',
is_enabled: false,
},
{
package_name: 'system',
package_title: 'System',
package_version: '1.3.0',
is_enabled: true,
},
]
);
expect(integrationDetails[0].target_version).toEqual('1.2.3');
expect(integrationDetails[1].target_version).toEqual('1.2.3');
});
});
});
});

View file

@ -0,0 +1,113 @@
/*
* 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 { EuiLink } from '@elastic/eui';
import { capitalize } from 'lodash';
import React from 'react';
import semver from 'semver';
import {
InstalledIntegration,
InstalledIntegrationArray,
RelatedIntegration,
RelatedIntegrationArray,
} from '../../../../../common/detection_engine/schemas/common';
/**
* Returns an `EuiLink` that will link to a given package/integration/version page within fleet
*
* @param integration IntegrationDetails describing a package/integrations installed state
* @param basePath kbn basepath for composing the fleet URL
*/
export const getIntegrationLink = (integration: IntegrationDetails, basePath: string) => {
const packageName = integration.package_name;
const integrationName = integration.integration_name;
const integrationTitle = integration.integration_title ?? integration.package_title;
const version = integration.version_satisfied
? integration.package_version
: integration.target_version;
const integrationURL =
version !== ''
? `${basePath}/app/integrations/detail/${packageName}-${version}/overview${
integrationName ? `?integration=${integrationName}` : ''
}`
: `${basePath}/app/integrations/detail/${packageName}`;
return (
<EuiLink href={integrationURL} target="_blank" data-test-subj={'integrationLink'}>
{integrationTitle}
</EuiLink>
);
};
export interface IntegrationDetails extends InstalledIntegration {
target_version: string;
version_satisfied: boolean;
is_installed: boolean;
}
/**
* Given an array of integrations and an array of installed integrations this will return an
* array of integrations augmented with install details like targetVersion, and `version_satisfied`
* has
* @param integrations
* @param installedIntegrations
*/
export const getInstalledRelatedIntegrations = (
integrations: RelatedIntegrationArray,
installedIntegrations: InstalledIntegrationArray | undefined
): IntegrationDetails[] => {
const integrationDetails: IntegrationDetails[] = [];
integrations.forEach((i: RelatedIntegration) => {
const match = installedIntegrations?.find(
(installed) =>
installed.package_name === i.package && installed?.integration_name === i?.integration
);
if (match != null) {
// Version check e.g. fleet match `1.2.3` satisfies rule dependency `~1.2.1`
const versionSatisfied = semver.satisfies(match.package_version, i.version);
const packageVersion = versionSatisfied
? i.version
: semver.valid(semver.coerce(i.version)) ?? '';
integrationDetails.push({
...match,
target_version: packageVersion,
version_satisfied: versionSatisfied,
is_installed: true,
});
} else {
const packageVersion = semver.valid(semver.coerce(i.version)) ?? '';
// TODO: Add `title` to RelatedIntegration (or fetch from Fleet API) so we can accurately display the integration pretty name
const integrationTitle =
i.integration != null ? `${capitalize(i.package)} ${capitalize(i.integration)}` : undefined;
integrationDetails.push({
package_name: i.package,
package_title: capitalize(i.package),
package_version: packageVersion,
integration_name: i.integration,
integration_title: integrationTitle,
target_version: packageVersion,
version_satisfied: false,
is_enabled: false,
is_installed: false,
});
}
});
return integrationDetails.sort((a, b) => {
if (a.integration_title != null && b.integration_title != null) {
return a.integration_title.localeCompare(b.integration_title);
} else if (a.integration_title != null) {
return a.integration_title.localeCompare(b.package_title);
} else if (b.integration_title != null) {
return a.package_title.localeCompare(b.integration_title);
} else {
return a.package_title.localeCompare(b.package_title);
}
});
};

View file

@ -7,6 +7,7 @@
import {
GetAggregateRuleExecutionEventsResponse,
GetInstalledIntegrationsResponse,
RulesSchema,
} from '../../../../../../common/detection_engine/schemas/response';
@ -104,3 +105,30 @@ export const fetchRuleExecutionEvents = async ({
export const fetchTags = async ({ signal }: { signal: AbortSignal }): Promise<string[]> =>
Promise.resolve(['elastic', 'love', 'quality', 'code']);
export const fetchInstalledIntegrations = async ({
packages,
signal,
}: {
packages?: string[];
signal?: AbortSignal;
}): Promise<GetInstalledIntegrationsResponse> => {
return Promise.resolve({
installed_integrations: [
{
package_name: 'atlassian_bitbucket',
package_title: 'Atlassian Bitbucket',
package_version: '1.0.1',
integration_name: 'audit',
integration_title: 'Audit Logs',
is_enabled: true,
},
{
package_name: 'system',
package_title: 'System',
package_version: '1.6.4',
is_enabled: true,
},
],
});
};

View file

@ -123,10 +123,3 @@ export const RULE_EXECUTION_EVENTS_FETCH_FAILURE = i18n.translate(
defaultMessage: 'Failed to fetch rule execution events',
}
);
export const INSTALLED_INTEGRATIONS_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.installedIntegrationsFetchFailDescription',
{
defaultMessage: 'Failed to fetch installed integrations',
}
);

View file

@ -15,11 +15,12 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useMemo } from 'react';
import { IntegrationsPopover } from '../../../../../common/components/integrations_popover';
import { IntegrationsPopover } from '../../../../components/rules/related_integrations/integrations_popover';
import {
APP_UI_ID,
DEFAULT_RELATIVE_DATE_THRESHOLD,
SecurityPageName,
SHOW_RELATED_INTEGRATIONS_SETTING,
} from '../../../../../../common/constants';
import { isMlRule } from '../../../../../../common/machine_learning/helpers';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
@ -28,7 +29,7 @@ import { LinkAnchor } from '../../../../../common/components/links';
import { useFormatUrl } from '../../../../../common/components/link_to';
import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine';
import { PopoverItems } from '../../../../../common/components/popover_items';
import { useKibana } from '../../../../../common/lib/kibana';
import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana';
import { canEditRuleWithActions, getToolTipContent } from '../../../../../common/utils/privileges';
import { RuleSwitch } from '../../../../components/rules/rule_switch';
import { SeverityBadge } from '../../../../components/rules/severity_badge';
@ -163,7 +164,7 @@ const INTEGRATIONS_COLUMN: TableColumn = {
name: null,
align: 'center',
render: (integrations: Rule['related_integrations']) => {
if (integrations?.length === 0) {
if (integrations == null || integrations.length === 0) {
return null;
}
@ -201,11 +202,12 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[]
const enabledColumn = useEnabledColumn({ hasPermissions });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
return useMemo(
() => [
ruleNameColumn,
INTEGRATIONS_COLUMN,
...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []),
TAGS_COLUMN,
{
field: 'risk_score',
@ -294,7 +296,14 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[]
enabledColumn,
...(hasPermissions ? [actionsColumn] : []),
],
[actionsColumn, enabledColumn, hasPermissions, isInMemorySorting, ruleNameColumn]
[
actionsColumn,
enabledColumn,
hasPermissions,
isInMemorySorting,
ruleNameColumn,
showRelatedIntegrations,
]
);
};
@ -304,6 +313,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol
const enabledColumn = useEnabledColumn({ hasPermissions });
const ruleNameColumn = useRuleNameColumn();
const { isInMemorySorting } = useRulesTableContext().state;
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
return useMemo(
() => [
@ -311,7 +321,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol
...ruleNameColumn,
width: '28%',
},
INTEGRATIONS_COLUMN,
...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []),
TAGS_COLUMN,
{
field: 'execution_summary.last_execution.metrics.total_indexing_duration_ms',
@ -426,6 +436,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol
hasPermissions,
isInMemorySorting,
ruleNameColumn,
showRelatedIntegrations,
]
);
};

View file

@ -1085,53 +1085,3 @@ export const RULES_BULK_EDIT_FAILURE_DESCRIPTION = (rulesCount: number) =>
defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} failed to update.',
}
);
export const INTEGRATIONS_BADGE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.integrations.badgeTitle',
{
defaultMessage: 'integrations',
}
);
export const INTEGRATIONS_POPOVER_TITLE = (integrationsCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverTitle',
{
values: { integrationsCount },
defaultMessage:
'You have [{integrationsCount}] related {integrationsCount, plural, =1 {integration} other {integrations}} to your prebuilt rule',
}
);
export const INTEGRATIONS_POPOVER_DESCRIPTION_INSTALLED = (installedCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionInstalledTitle',
{
values: { installedCount },
defaultMessage:
'You have [{installedCount}] related {installedCount, plural, =1 {integration} other {integrations}} installed, click the link below to view the integration:',
}
);
export const INTEGRATIONS_POPOVER_DESCRIPTION_UNINSTALLED = (uninstalledCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionUninstalledTitle',
{
values: { uninstalledCount },
defaultMessage:
'You have [{uninstalledCount}] related {uninstalledCount, plural, =1 {integration} other {integrations}} uninstalled, click the link to add integration:',
}
);
export const INTEGRATIONS_INSTALLED_VERSION_TOOLTIP = (
installedVersion: string,
targetVersion: string
) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionInstalledVersionTooltip',
{
values: { installedVersion, targetVersion },
defaultMessage:
'Version mismatch -- please resolve! Installed version `{installedVersion}` when target version `{targetVersion}`',
}
);

View file

@ -32,6 +32,7 @@ import {
NEWS_FEED_URL_SETTING,
NEWS_FEED_URL_SETTING_DEFAULT,
ENABLE_CCS_READ_WARNING_SETTING,
SHOW_RELATED_INTEGRATIONS_SETTING,
} from '../common/constants';
import { ExperimentalFeatures } from '../common/experimental_features';
@ -186,7 +187,7 @@ export const initUiSettings = (
'xpack.securitySolution.uiSettings.rulesTableRefreshDescription',
{
defaultMessage:
'<p>Enables auto refresh on the all rules and monitoring tables, in milliseconds</p>',
'<p>Enables auto refresh on the rules and monitoring tables, in milliseconds</p>',
}
),
type: 'json',
@ -250,6 +251,22 @@ export const initUiSettings = (
requiresPageReload: false,
schema: schema.boolean(),
},
[SHOW_RELATED_INTEGRATIONS_SETTING]: {
name: i18n.translate('xpack.securitySolution.uiSettings.showRelatedIntegrationsLabel', {
defaultMessage: 'Related integrations',
}),
value: true,
description: i18n.translate(
'xpack.securitySolution.uiSettings.showRelatedIntegrationsDescription',
{
defaultMessage: '<p>Shows related integrations on the rules and monitoring tables</p>',
}
),
type: 'boolean',
category: [APP_ID],
requiresPageReload: true,
schema: schema.boolean(),
},
};
uiSettings.register(orderSettings(securityUiSettings));