mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[fleet] Add Integration Preference selector (#114432)
This commit is contained in:
parent
45e07af1fa
commit
8068eecbde
12 changed files with 536 additions and 6327 deletions
File diff suppressed because it is too large
Load diff
|
@ -14,7 +14,7 @@ export const searchIdField = 'id';
|
|||
export const fieldsToSearch = ['name', 'title', 'description'];
|
||||
|
||||
export function useLocalSearch(packageList: IntegrationCardItem[]) {
|
||||
const localSearchRef = useRef<LocalSearch | null>(null);
|
||||
const localSearchRef = useRef<LocalSearch>(new LocalSearch(searchIdField));
|
||||
|
||||
useEffect(() => {
|
||||
const localSearch = new LocalSearch(searchIdField);
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { IntegrationPreference as Component } from './integration_preference';
|
||||
|
||||
export default {
|
||||
title: 'Sections/EPM/Integration Preference',
|
||||
description: '',
|
||||
decorators: [
|
||||
(storyFn, { globals }) => (
|
||||
<div
|
||||
style={{
|
||||
padding: 40,
|
||||
backgroundColor:
|
||||
globals.euiTheme === 'v8.dark' || globals.euiTheme === 'v7.dark' ? '#1D1E24' : '#FFF',
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
{storyFn()}
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
export const IntegrationPreference = () => {
|
||||
return <Component initialType="recommended" onChange={action('onChange')} />;
|
||||
};
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiPanel,
|
||||
EuiLink,
|
||||
EuiText,
|
||||
EuiForm,
|
||||
EuiRadioGroup,
|
||||
EuiSpacer,
|
||||
EuiIconTip,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
|
||||
export type IntegrationPreferenceType = 'recommended' | 'beats' | 'agent';
|
||||
|
||||
interface Option {
|
||||
type: IntegrationPreferenceType;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
initialType: IntegrationPreferenceType;
|
||||
onChange: (type: IntegrationPreferenceType) => void;
|
||||
}
|
||||
|
||||
const link = (
|
||||
<EuiLink href="#">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.integrationPreference.titleLink"
|
||||
defaultMessage="Elastic Agent and Beats"
|
||||
/>
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.integrationPreference.title"
|
||||
defaultMessage="When an integration is available for {link}, show:"
|
||||
values={{ link }}
|
||||
/>
|
||||
);
|
||||
|
||||
const recommendedTooltip = (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.epm.integrationPreference.recommendedTooltip"
|
||||
defaultMessage="Generally available (GA) integrations are recommended over beta and experimental."
|
||||
/>
|
||||
);
|
||||
|
||||
const Item = styled(EuiFlexItem)`
|
||||
padding-left: ${(props) => props.theme.eui.euiSizeXS};
|
||||
`;
|
||||
|
||||
const options: Option[] = [
|
||||
{
|
||||
type: 'recommended',
|
||||
label: (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('xpack.fleet.epm.integrationPreference.recommendedLabel', {
|
||||
defaultMessage: 'Recommended',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<Item>
|
||||
<EuiIconTip content={recommendedTooltip} />
|
||||
</Item>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'agent',
|
||||
label: i18n.translate('xpack.fleet.epm.integrationPreference.elasticAgentLabel', {
|
||||
defaultMessage: 'Elastic Agent only',
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'beats',
|
||||
label: i18n.translate('xpack.fleet.epm.integrationPreference.beatsLabel', {
|
||||
defaultMessage: 'Beats only',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const IntegrationPreference = ({ initialType, onChange }: Props) => {
|
||||
const [idSelected, setIdSelected] = React.useState<IntegrationPreferenceType>(initialType);
|
||||
const radios = options.map((option) => ({
|
||||
id: option.type,
|
||||
value: option.type,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<EuiPanel hasShadow={false} paddingSize="none">
|
||||
<EuiText size="s">{title}</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiForm>
|
||||
<EuiRadioGroup
|
||||
options={radios}
|
||||
idSelected={idSelected}
|
||||
onChange={(id, value) => {
|
||||
setIdSelected(id as IntegrationPreferenceType);
|
||||
onChange(value as IntegrationPreferenceType);
|
||||
}}
|
||||
name="preference"
|
||||
/>
|
||||
</EuiForm>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -30,7 +30,7 @@ import { PackageCard } from './package_card';
|
|||
|
||||
export interface Props {
|
||||
isLoading?: boolean;
|
||||
controls?: ReactNode;
|
||||
controls?: ReactNode | ReactNode[];
|
||||
title: string;
|
||||
list: IntegrationCardItem[];
|
||||
initialSearch?: string;
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import { useLocation, useHistory, useParams } from 'react-router-dom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import _ from 'lodash';
|
||||
import { EuiHorizontalRule } from '@elastic/eui';
|
||||
|
||||
import { pagePathGetters } from '../../../../constants';
|
||||
import {
|
||||
|
@ -31,6 +32,9 @@ import type { IntegrationCardItem } from '../../../../../../../common/types/mode
|
|||
|
||||
import { useMergeEprPackagesWithReplacements } from '../../../../hooks/use_merge_epr_with_replacements';
|
||||
|
||||
import type { IntegrationPreferenceType } from '../../components/integration_preference';
|
||||
import { IntegrationPreference } from '../../components/integration_preference';
|
||||
|
||||
import { mergeCategoriesAndCount } from './util';
|
||||
import { ALL_CATEGORY, CategoryFacets } from './category_facets';
|
||||
import type { CategoryFacet } from './category_facets';
|
||||
|
@ -96,11 +100,14 @@ const title = i18n.translate('xpack.fleet.epmList.allTitle', {
|
|||
// TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http`
|
||||
// or `location` to load data. Ideally, we'll split this into "connected" and "pure" components.
|
||||
export const AvailablePackages: React.FC = memo(() => {
|
||||
const [preference, setPreference] = useState<IntegrationPreferenceType>('recommended');
|
||||
useBreadcrumbs('integrations_all');
|
||||
|
||||
const { selectedCategory, searchParam } = getParams(
|
||||
useParams<CategoryParams>(),
|
||||
useLocation().search
|
||||
);
|
||||
|
||||
const history = useHistory();
|
||||
const { getHref, getAbsolutePath } = useLink();
|
||||
|
||||
|
@ -111,6 +118,7 @@ export const AvailablePackages: React.FC = memo(() => {
|
|||
})[1];
|
||||
history.push(url);
|
||||
}
|
||||
|
||||
function setSearchTerm(search: string) {
|
||||
// Use .replace so the browser's back button is not tied to single keystroke
|
||||
history.replace(
|
||||
|
@ -121,25 +129,32 @@ export const AvailablePackages: React.FC = memo(() => {
|
|||
const { data: eprPackages, isLoading: isLoadingAllPackages } = useGetPackages({
|
||||
category: '',
|
||||
});
|
||||
|
||||
const eprIntegrationList = useMemo(
|
||||
() => packageListToIntegrationsList(eprPackages?.response || []),
|
||||
[eprPackages]
|
||||
);
|
||||
|
||||
const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations();
|
||||
|
||||
const mergedEprPackages: Array<PackageListItem | CustomIntegration> =
|
||||
useMergeEprPackagesWithReplacements(
|
||||
eprIntegrationList || [],
|
||||
replacementCustomIntegrations || []
|
||||
preference === 'beats' ? [] : eprIntegrationList,
|
||||
preference === 'agent' ? [] : replacementCustomIntegrations || []
|
||||
);
|
||||
|
||||
const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } =
|
||||
useGetAppendCustomIntegrations();
|
||||
|
||||
const eprAndCustomPackages: Array<CustomIntegration | PackageListItem> = [
|
||||
...mergedEprPackages,
|
||||
...(appendCustomIntegrations || []),
|
||||
];
|
||||
|
||||
const cards: IntegrationCardItem[] = eprAndCustomPackages.map((item) => {
|
||||
return mapToCard(getAbsolutePath, getHref, item);
|
||||
});
|
||||
|
||||
cards.sort((a, b) => {
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
@ -147,6 +162,7 @@ export const AvailablePackages: React.FC = memo(() => {
|
|||
const { data: eprCategories, isLoading: isLoadingCategories } = useGetCategories({
|
||||
include_policy_templates: true,
|
||||
});
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const eprAndCustomCategories: CategoryFacet[] =
|
||||
isLoadingCategories || !eprCategories
|
||||
|
@ -169,16 +185,24 @@ export const AvailablePackages: React.FC = memo(() => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const controls = categories ? (
|
||||
<CategoryFacets
|
||||
isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAppendCustomIntegrations}
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={({ id }: CategoryFacet) => {
|
||||
setSelectedCategory(id);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
let controls = [
|
||||
<EuiHorizontalRule />,
|
||||
<IntegrationPreference initialType={preference} onChange={setPreference} />,
|
||||
];
|
||||
|
||||
if (categories) {
|
||||
controls = [
|
||||
<CategoryFacets
|
||||
isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAppendCustomIntegrations}
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={({ id }) => {
|
||||
setSelectedCategory(id);
|
||||
}}
|
||||
/>,
|
||||
...controls,
|
||||
];
|
||||
}
|
||||
|
||||
const filteredCards = cards.filter((c) => {
|
||||
if (selectedCategory === '') {
|
||||
|
|
|
@ -14,7 +14,6 @@ import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants';
|
|||
import { EPMHomePage as Component } from '.';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'Sections/EPM/Home',
|
||||
};
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../..
|
|||
import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants';
|
||||
import { DefaultLayout } from '../../../../layouts';
|
||||
|
||||
import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common';
|
||||
import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common';
|
||||
|
||||
import type { PackageListItem } from '../../../../types';
|
||||
|
@ -31,7 +32,10 @@ export const getParams = (params: CategoryParams, search: string) => {
|
|||
const selectedCategory = category || '';
|
||||
const queryParams = new URLSearchParams(search);
|
||||
const searchParam = queryParams.get(INTEGRATIONS_SEARCH_QUERYPARAM) || '';
|
||||
return { selectedCategory, searchParam };
|
||||
return { selectedCategory, searchParam } as {
|
||||
selectedCategory: IntegrationCategory & '';
|
||||
searchParam: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const categoryExists = (category: string, categories: CategoryFacet[]) => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CustomIntegration } from '../../../../../../src/plugins/custom_integrations/common';
|
||||
|
||||
export const integrations: CustomIntegration[] = [
|
||||
{
|
||||
id: 'b.ga_beats',
|
||||
categories: ['azure', 'cloud', 'config_management'],
|
||||
description: 'Beats for a GA package that is not installed',
|
||||
isBeta: false,
|
||||
shipper: 'beats',
|
||||
icons: [
|
||||
{
|
||||
type: 'eui',
|
||||
src: 'logoBeats',
|
||||
},
|
||||
],
|
||||
title: 'b. GA, has Beats',
|
||||
type: 'ui_link',
|
||||
uiInternalPath: '/',
|
||||
eprOverlap: 'ga_beats',
|
||||
},
|
||||
{
|
||||
id: 'f.beta_beats',
|
||||
categories: ['azure', 'cloud', 'config_management'],
|
||||
description: 'Beats for a beta package that is not installed',
|
||||
isBeta: false,
|
||||
shipper: 'beats',
|
||||
icons: [
|
||||
{
|
||||
type: 'eui',
|
||||
src: 'logoBeats',
|
||||
},
|
||||
],
|
||||
title: 'f. Beta, has Beats',
|
||||
type: 'ui_link',
|
||||
uiInternalPath: '/',
|
||||
eprOverlap: 'beta_beats',
|
||||
},
|
||||
{
|
||||
id: 'j.exp_beats',
|
||||
categories: ['azure', 'cloud', 'config_management'],
|
||||
description: 'Beats for an experimental package that is not installed',
|
||||
isBeta: false,
|
||||
shipper: 'beats',
|
||||
icons: [
|
||||
{
|
||||
type: 'eui',
|
||||
src: 'logoBeats',
|
||||
},
|
||||
],
|
||||
title: 'j. Experimental, has Beats',
|
||||
type: 'ui_link',
|
||||
uiInternalPath: '/',
|
||||
eprOverlap: 'exp_beats',
|
||||
},
|
||||
];
|
|
@ -27,6 +27,7 @@ export const getHttp = (basepath = BASE_PATH) => {
|
|||
serverBasePath: basepath,
|
||||
},
|
||||
get: (async (path: string, options: HttpFetchOptions) => {
|
||||
action('get')(path, options);
|
||||
// TODO: all of this needs revision, as it's far too clunky... but it works for now,
|
||||
// with the few paths we're supporting.
|
||||
if (path === '/api/fleet/agents/setup') {
|
||||
|
@ -74,6 +75,7 @@ export const getHttp = (basepath = BASE_PATH) => {
|
|||
return await import('./fixtures/integration.okta');
|
||||
}
|
||||
|
||||
action(path)('KP: UNSUPPORTED ROUTE');
|
||||
return {};
|
||||
}) as HttpHandler,
|
||||
} as unknown as HttpStart;
|
||||
|
|
|
@ -66,7 +66,10 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
|
|||
setHttpClient(startServices.http);
|
||||
setCustomIntegrations({
|
||||
getAppendCustomIntegrations: async () => [],
|
||||
getReplacementCustomIntegrations: async () => [],
|
||||
getReplacementCustomIntegrations: async () => {
|
||||
const { integrations } = await import('./fixtures/replacement_integrations');
|
||||
return integrations;
|
||||
},
|
||||
});
|
||||
|
||||
const config = {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue