From ea7012ebb1732d2011e10ee658d75ba0909c79fe Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Wed, 8 Jul 2020 09:58:32 -0500 Subject: [PATCH 01/10] Index Patterns Management - use `/_resolve` endpoint for data streams support (#70271) * Index Patterns Management - use `/_resolve` endpoint for data streams support --- .../index_pattern_management/kibana.json | 2 +- .../create_index_pattern_wizard.test.tsx.snap | 153 +++---- .../header/__snapshots__/header.test.tsx.snap | 394 +++++++++++------- .../components/header/header.test.tsx | 21 +- .../components/header/header.tsx | 81 ++-- .../step_index_pattern.test.tsx.snap | 4 + .../header/__snapshots__/header.test.tsx.snap | 192 +++++---- .../components/header/header.test.tsx | 4 + .../components/header/header.tsx | 86 ++-- .../indices_list/indices_list.test.tsx | 5 +- .../components/indices_list/indices_list.tsx | 6 +- .../status_message.test.tsx.snap | 112 ++--- .../status_message/status_message.test.tsx | 11 +- .../status_message/status_message.tsx | 97 ++--- .../step_index_pattern.test.tsx | 59 ++- .../step_index_pattern/step_index_pattern.tsx | 98 +++-- .../step_time_field.test.tsx.snap | 109 ++--- .../header/__snapshots__/header.test.tsx.snap | 19 +- .../components/header/header.tsx | 11 +- .../__snapshots__/time_field.test.tsx.snap | 212 ++++------ .../components/time_field/time_field.tsx | 126 +++--- .../step_time_field/step_time_field.tsx | 40 +- .../create_index_pattern_wizard.tsx | 116 +++--- .../__snapshots__/get_indices.test.ts.snap | 69 +++ .../lib/get_indices.test.ts | 174 +++----- .../lib/get_indices.ts | 118 +++--- .../lib/get_matched_indices.test.ts | 8 +- .../lib/get_matched_indices.ts | 10 +- .../create_index_pattern_wizard/types.ts | 44 +- .../__snapshots__/field_editor.test.tsx.snap | 1 - .../warning_call_out.test.tsx.snap | 20 +- .../index_pattern_management/public/mocks.ts | 8 +- .../public/service/creation/config.ts | 4 +- .../index_pattern_management/server/index.ts | 25 ++ .../index_pattern_management/server/plugin.ts | 70 ++++ .../_create_index_pattern_wizard.js | 55 +++ .../rollup_index_pattern_creation_config.js | 1 + 37 files changed, 1412 insertions(+), 1153 deletions(-) create mode 100644 src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/__snapshots__/get_indices.test.ts.snap create mode 100644 src/plugins/index_pattern_management/server/index.ts create mode 100644 src/plugins/index_pattern_management/server/plugin.ts diff --git a/src/plugins/index_pattern_management/kibana.json b/src/plugins/index_pattern_management/kibana.json index 364edbb030dc9..23adef2626a72 100644 --- a/src/plugins/index_pattern_management/kibana.json +++ b/src/plugins/index_pattern_management/kibana.json @@ -1,7 +1,7 @@ { "id": "indexPatternManagement", "version": "kibana", - "server": false, + "server": true, "ui": true, "requiredPlugins": ["management", "data", "kibanaLegacy"] } diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap index 5c955bbd3283e..70200e03c0dbe 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap @@ -1,41 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CreateIndexPatternWizard defaults to the loading state 1`] = ` - -
-
- -
+ + -
+ `; exports[`CreateIndexPatternWizard renders index pattern step when there are indices 1`] = ` - -
+ +
+ -
+ -
+ `; exports[`CreateIndexPatternWizard renders the empty state when there are no indices 1`] = ` - -
-
- -
+ + -
+ `; exports[`CreateIndexPatternWizard renders time field step when step is set to 2 1`] = ` - -
+ +
+ -
+ -
+ `; exports[`CreateIndexPatternWizard renders when there are no indices but there are remote clusters 1`] = ` - -
+ +
+ -
+ -
+ `; exports[`CreateIndexPatternWizard shows system indices even if there are no other indices if the include system indices is toggled 1`] = ` - -
-
- -
+ + -
+ `; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap index 81ca3e644d3ce..6a2fd1000e6b4 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap @@ -2,10 +2,15 @@ exports[`Header should render a different name, prompt, and beta tag if provided 1`] = `
Test prompt @@ -31,76 +36,114 @@ exports[`Header should render a different name, prompt, and beta tag if provided -
+ + +
- -
+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } + > + + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data souces, + + + + + filebeat-* + + + + + . + + +
+ - -
-

- - - - - Kibana uses index patterns to retrieve data from Elasticsearch indices for things like visualizations. - - - - -

-
-
-
-
+ + Read documentation + + + + +

- +
Test prompt
- -
-
`; exports[`Header should render normally 1`] = `
@@ -110,66 +153,104 @@ exports[`Header should render normally 1`] = ` Create test index pattern -
+ + +
- -
+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } > - + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data souces, + + + + + filebeat-* + + + + + . + + +
+ +
-
+ + Read documentation + + + + +

- - -
- +
`; exports[`Header should render without including system indices 1`] = `
@@ -179,57 +260,90 @@ exports[`Header should render without including system indices 1`] = ` Create test index pattern -
+ + +
- -
+ + multiple + , + "single": + filebeat-4-3-22 + , + "star": + filebeat-* + , + } + } + > + + An index pattern can match a single source, for example, + + + + + filebeat-4-3-22 + + + + + , or + + multiple + + data souces, + + + + + filebeat-* + + + + + . + + +
+ - -
-

- - - - - Kibana uses index patterns to retrieve data from Elasticsearch indices for things like visualizations. - - - - -

-
-
-
-
+ + Read documentation + + + + +

- - -
- +
`; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.test.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.test.tsx index d12e0401380b9..865b3ec353f76 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.test.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.test.tsx @@ -22,18 +22,20 @@ import { Header } from '../header'; import { mount } from 'enzyme'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; import { mockManagementPlugin } from '../../../../mocks'; +import { DocLinksStart } from 'kibana/public'; describe('Header', () => { const indexPatternName = 'test index pattern'; const mockedContext = mockManagementPlugin.createIndexPatternManagmentContext(); + const mockedDocLinks = { + links: { + indexPatterns: {}, + }, + } as DocLinksStart; it('should render normally', () => { const component = mount( -
{}} - />, +
, { wrappingComponent: KibanaContextProvider, wrappingComponentProps: { @@ -47,11 +49,7 @@ describe('Header', () => { it('should render without including system indices', () => { const component = mount( -
{}} - />, +
, { wrappingComponent: KibanaContextProvider, wrappingComponentProps: { @@ -66,11 +64,10 @@ describe('Header', () => { it('should render a different name, prompt, and beta tag if provided', () => { const component = mount(
{}} prompt={
Test prompt
} indexPatternName={indexPatternName} isBeta={true} + docLinks={mockedDocLinks} />, { wrappingComponent: KibanaContextProvider, diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx index 35c6e67d0ea0e..f90425311142d 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx @@ -17,38 +17,26 @@ * under the License. */ -import React, { Fragment } from 'react'; +import React from 'react'; -import { - EuiBetaBadge, - EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiTextColor, - EuiSwitch, -} from '@elastic/eui'; +import { EuiBetaBadge, EuiSpacer, EuiTitle, EuiText, EuiCode, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { DocLinksStart } from 'kibana/public'; import { useKibana } from '../../../../../../../plugins/kibana_react/public'; import { IndexPatternManagmentContext } from '../../../../types'; export const Header = ({ prompt, indexPatternName, - showSystemIndices = false, - isIncludingSystemIndices, - onChangeIncludingSystemIndices, isBeta = false, + docLinks, }: { prompt?: React.ReactNode; indexPatternName: string; - showSystemIndices?: boolean; - isIncludingSystemIndices: boolean; - onChangeIncludingSystemIndices: () => void; isBeta?: boolean; + docLinks: DocLinksStart; }) => { const changeTitle = useKibana().services.chrome.docTitle.change; const createIndexPatternHeader = i18n.translate( @@ -67,53 +55,44 @@ export const Header = ({

{createIndexPatternHeader} {isBeta ? ( - + <> {' '} - + ) : null}

- - - -

- - - -

-
-
- {showSystemIndices ? ( - - - } - id="checkboxShowSystemIndices" - checked={isIncludingSystemIndices} - onChange={onChangeIncludingSystemIndices} + + +

+ multiple, + single: filebeat-4-3-22, + star: filebeat-*, + }} + /> +
+ + - - ) : null} - + +

+
{prompt ? ( - - + <> + {prompt} - + ) : null} - ); }; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/__snapshots__/step_index_pattern.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/__snapshots__/step_index_pattern.test.tsx.snap index b68ba4720b935..813a0c61c0829 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/__snapshots__/step_index_pattern.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/__snapshots__/step_index_pattern.test.tsx.snap @@ -11,8 +11,10 @@ Object { ] } goToNextStep={[Function]} + isIncludingSystemIndices={false} isInputInvalid={true} isNextStepDisabled={true} + onChangeIncludingSystemIndices={[Function]} onQueryChanged={[Function]} query="?" />, @@ -25,6 +27,7 @@ exports[`StepIndexPattern renders indices which match the initial query 1`] = ` indices={ Array [ Object { + "item": Object {}, "name": "kibana", }, ] @@ -39,6 +42,7 @@ exports[`StepIndexPattern renders matching indices when input is valid 1`] = ` indices={ Array [ Object { + "item": Object {}, "name": "kibana", }, ] diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/__snapshots__/header.test.tsx.snap index 3021292953ff5..c4f735558b1f2 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/__snapshots__/header.test.tsx.snap @@ -16,13 +16,8 @@ exports[`Header should mark the input as invalid 1`] = ` - - + + @@ -34,43 +29,40 @@ exports[`Header should mark the input as invalid 1`] = ` "Input is invalid", ] } - fullWidth={false} + fullWidth={true} hasChildLabel={true} hasEmptyLabelSpace={false} helpText={ -
-

- - * - , - } + + + * + , } - /> -

-

- - % - , - } + } + /> + + + % + , } - /> -

-
+ } + /> + } isInvalid={true} label={ @@ -79,6 +71,7 @@ exports[`Header should mark the input as invalid 1`] = ` > - - - + + + +
@@ -124,13 +128,8 @@ exports[`Header should render normally 1`] = ` - - + + @@ -138,43 +137,40 @@ exports[`Header should render normally 1`] = ` describedByIds={Array []} display="row" error={Array []} - fullWidth={false} + fullWidth={true} hasChildLabel={true} hasEmptyLabelSpace={false} helpText={ -
-

- - * - , - } + + + * + , } - /> -

-

- - % - , - } + } + /> + + + % + , } - /> -

-
+ } + /> + } isInvalid={false} label={ @@ -183,6 +179,7 @@ exports[`Header should render normally 1`] = ` > - - - + + + +
diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.test.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.test.tsx index f56340d0009be..acc133a4dd649 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.test.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.test.tsx @@ -32,6 +32,8 @@ describe('Header', () => { onQueryChanged={() => {}} goToNextStep={() => {}} isNextStepDisabled={false} + onChangeIncludingSystemIndices={() => {}} + isIncludingSystemIndices={false} /> ); @@ -48,6 +50,8 @@ describe('Header', () => { onQueryChanged={() => {}} goToNextStep={() => {}} isNextStepDisabled={true} + onChangeIncludingSystemIndices={() => {}} + isIncludingSystemIndices={false} /> ); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.tsx index 9ce72aeeea6e3..f1bf0d54a1cbf 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/header/header.tsx @@ -28,6 +28,8 @@ import { EuiForm, EuiFormRow, EuiFieldText, + EuiSwitchEvent, + EuiSwitch, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -41,6 +43,9 @@ interface HeaderProps { onQueryChanged: (e: React.ChangeEvent) => void; goToNextStep: (query: string) => void; isNextStepDisabled: boolean; + showSystemIndices?: boolean; + onChangeIncludingSystemIndices: (event: EuiSwitchEvent) => void; + isIncludingSystemIndices: boolean; } export const Header: React.FC = ({ @@ -51,6 +56,9 @@ export const Header: React.FC = ({ onQueryChanged, goToNextStep, isNextStepDisabled, + showSystemIndices = false, + onChangeIncludingSystemIndices, + isIncludingSystemIndices, ...rest }) => (
@@ -63,35 +71,32 @@ export const Header: React.FC = ({ - - + + } isInvalid={isInputInvalid} error={errors} helpText={ -
-

- * }} - /> -

-

- {characterList} }} - /> -

-
+ <> + * }} + />{' '} + {characterList} }} + /> + } > = ({ isInvalid={isInputInvalid} onChange={onQueryChanged} data-test-subj="createIndexPatternNameInput" + fullWidth />
+ + {showSystemIndices ? ( + + + } + id="checkboxShowSystemIndices" + checked={isIncludingSystemIndices} + onChange={onChangeIncludingSystemIndices} + /> + + ) : null}
- goToNextStep(query)} - isDisabled={isNextStepDisabled} - data-test-subj="createIndexPatternGoToStep2Button" - > - - + + goToNextStep(query)} + isDisabled={isNextStepDisabled} + data-test-subj="createIndexPatternGoToStep2Button" + > + + +
diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.test.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.test.tsx index d8a1d1a0ab72f..fbd60cbe3d131 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.test.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.test.tsx @@ -20,11 +20,12 @@ import React from 'react'; import { IndicesList } from '../indices_list'; import { shallow } from 'enzyme'; +import { MatchedItem } from '../../../../types'; -const indices = [ +const indices = ([ { name: 'kibana', tags: [] }, { name: 'es', tags: [] }, -]; +] as unknown) as MatchedItem[]; describe('IndicesList', () => { it('should render normally', () => { diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.tsx index c590d2a7ddfe2..4a051ee698209 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/indices_list.tsx @@ -39,10 +39,10 @@ import { Pager } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PER_PAGE_INCREMENTS } from '../../../../constants'; -import { MatchedIndex, Tag } from '../../../../types'; +import { MatchedItem, Tag } from '../../../../types'; interface IndicesListProps { - indices: MatchedIndex[]; + indices: MatchedItem[]; query: string; } @@ -187,7 +187,7 @@ export class IndicesList extends React.Component {index.tags.map((tag: Tag) => { return ( - + {tag.name} ); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/__snapshots__/status_message.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/__snapshots__/status_message.test.tsx.snap index 4a063f1430d1c..44b753c473803 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/__snapshots__/status_message.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/__snapshots__/status_message.test.tsx.snap @@ -1,67 +1,44 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`StatusMessage should render with exact matches 1`] = ` - - - + title={   - - , - "strongSuccess": - - , + "sourceCount": 1, } } /> - - + } +/> `; exports[`StatusMessage should render with no partial matches 1`] = ` - - + title={ - - + } +/> `; exports[`StatusMessage should render with partial matches 1`] = ` - - + title={ - - + } +/> `; exports[`StatusMessage should render without a query 1`] = ` - - + title={ - 2 - indices - , + "sourceCount": 2, } } /> - - + } +/> `; exports[`StatusMessage should show that no indices exist 1`] = ` - - + title={ - - + } +/> `; exports[`StatusMessage should show that system indices exist 1`] = ` - - + title={ - - + } +/> `; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.test.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.test.tsx index 899c21d59c5bc..f97c9ffe8a364 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.test.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.test.tsx @@ -20,18 +20,19 @@ import React from 'react'; import { StatusMessage } from '../status_message'; import { shallow } from 'enzyme'; +import { MatchedItem } from '../../../../types'; const tagsPartial = { tags: [], }; const matchedIndices = { - allIndices: [ + allIndices: ([ { name: 'kibana', ...tagsPartial }, { name: 'es', ...tagsPartial }, - ], - exactMatchedIndices: [], - partialMatchedIndices: [{ name: 'kibana', ...tagsPartial }], + ] as unknown) as MatchedItem[], + exactMatchedIndices: [] as MatchedItem[], + partialMatchedIndices: ([{ name: 'kibana', ...tagsPartial }] as unknown) as MatchedItem[], }; describe('StatusMessage', () => { @@ -51,7 +52,7 @@ describe('StatusMessage', () => { it('should render with exact matches', () => { const localMatchedIndices = { ...matchedIndices, - exactMatchedIndices: [{ name: 'kibana', ...tagsPartial }], + exactMatchedIndices: ([{ name: 'kibana', ...tagsPartial }] as unknown) as MatchedItem[], }; const component = shallow( diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.tsx index ccdd1833ea9bf..22b75071b93bb 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/status_message/status_message.tsx @@ -19,16 +19,17 @@ import React from 'react'; -import { EuiText, EuiTextColor, EuiIcon } from '@elastic/eui'; +import { EuiCallOut } from '@elastic/eui'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MatchedIndex } from '../../../../types'; +import { MatchedItem } from '../../../../types'; interface StatusMessageProps { matchedIndices: { - allIndices: MatchedIndex[]; - exactMatchedIndices: MatchedIndex[]; - partialMatchedIndices: MatchedIndex[]; + allIndices: MatchedItem[]; + exactMatchedIndices: MatchedItem[]; + partialMatchedIndices: MatchedItem[]; }; isIncludingSystemIndices: boolean; query: string; @@ -41,23 +42,26 @@ export const StatusMessage: React.FC = ({ query, showSystemIndices, }) => { - let statusIcon; + let statusIcon: EuiIconType | undefined; let statusMessage; - let statusColor: 'default' | 'secondary' | undefined; + let statusColor: 'primary' | 'success' | 'warning' | undefined; const allIndicesLength = allIndices.length; if (query.length === 0) { - statusIcon = null; - statusColor = 'default'; + statusIcon = undefined; + statusColor = 'primary'; - if (allIndicesLength > 1) { + if (allIndicesLength >= 1) { statusMessage = ( {allIndicesLength} indices }} + defaultMessage="Your index pattern can match {sourceCount, plural, + one {your # source} + other {any of your # sources} + }." + values={{ sourceCount: allIndicesLength }} /> ); @@ -66,8 +70,7 @@ export const StatusMessage: React.FC = ({ ); @@ -83,51 +86,44 @@ export const StatusMessage: React.FC = ({ } } else if (exactMatchedIndices.length) { statusIcon = 'check'; - statusColor = 'secondary'; + statusColor = 'success'; statusMessage = (   - - - ), - strongIndices: ( - - - - ), + sourceCount: exactMatchedIndices.length, }} /> ); } else if (partialMatchedIndices.length) { - statusIcon = null; - statusColor = 'default'; + statusIcon = undefined; + statusColor = 'primary'; statusMessage = ( @@ -137,20 +133,26 @@ export const StatusMessage: React.FC = ({ ); } else if (allIndicesLength) { - statusIcon = null; - statusColor = 'default'; + statusIcon = undefined; + statusColor = 'warning'; statusMessage = ( @@ -163,11 +165,12 @@ export const StatusMessage: React.FC = ({ } return ( - - - {statusIcon ? : null} - {statusMessage} - - + ); }; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.test.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.test.tsx index 053940270c2b6..c88918041ca81 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.test.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { SavedObjectsFindResponsePublic } from 'kibana/public'; -import { StepIndexPattern } from '../step_index_pattern'; +import { StepIndexPattern, canPreselectTimeField } from './step_index_pattern'; import { Header } from './components/header'; import { IndexPatternCreationConfig } from '../../../../../../../plugins/index_pattern_management/public'; import { mockManagementPlugin } from '../../../../mocks'; @@ -38,16 +38,16 @@ const mockIndexPatternCreationType = new IndexPatternCreationConfig({ jest.mock('../../lib/get_indices', () => ({ getIndices: ({}, {}, query: string) => { if (query.startsWith('e')) { - return [{ name: 'es' }]; + return [{ name: 'es', item: {} }]; } - return [{ name: 'kibana' }]; + return [{ name: 'kibana', item: {} }]; }, })); const allIndices = [ - { name: 'kibana', tags: [] }, - { name: 'es', tags: [] }, + { name: 'kibana', tags: [], item: {} }, + { name: 'es', tags: [], item: {} }, ]; const goToNextStep = () => {}; @@ -205,4 +205,53 @@ describe('StepIndexPattern', () => { await new Promise((resolve) => process.nextTick(resolve)); expect(component.state('exactMatchedIndices')).toEqual([]); }); + + it('it can preselect time field', async () => { + const dataStream1 = { + name: 'data stream 1', + tags: [], + item: { name: 'data stream 1', backing_indices: [], timestamp_field: 'timestamp_field' }, + }; + + const dataStream2 = { + name: 'data stream 2', + tags: [], + item: { name: 'data stream 2', backing_indices: [], timestamp_field: 'timestamp_field' }, + }; + + const differentDataStream = { + name: 'different data stream', + tags: [], + item: { name: 'different data stream 2', backing_indices: [], timestamp_field: 'x' }, + }; + + const index = { + name: 'index', + tags: [], + item: { + name: 'index', + }, + }; + + const alias = { + name: 'alias', + tags: [], + item: { + name: 'alias', + indices: [], + }, + }; + + expect(canPreselectTimeField([index])).toEqual(undefined); + expect(canPreselectTimeField([alias])).toEqual(undefined); + expect(canPreselectTimeField([index, alias, dataStream1])).toEqual(undefined); + + expect(canPreselectTimeField([dataStream1])).toEqual('timestamp_field'); + + expect(canPreselectTimeField([dataStream1, dataStream2])).toEqual('timestamp_field'); + + expect(canPreselectTimeField([dataStream1, dataStream2, differentDataStream])).toEqual( + undefined + ); + }); }); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx index b6205a8731dfa..5797149a51aea 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.tsx @@ -18,7 +18,7 @@ */ import React, { Component } from 'react'; -import { EuiPanel, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiSwitchEvent } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -26,7 +26,6 @@ import { IndexPatternAttributes, UI_SETTINGS, } from '../../../../../../../plugins/data/public'; -import { MAX_SEARCH_SIZE } from '../../constants'; import { getIndices, containsIllegalCharacters, @@ -40,20 +39,20 @@ import { IndicesList } from './components/indices_list'; import { Header } from './components/header'; import { context as contextType } from '../../../../../../kibana_react/public'; import { IndexPatternCreationConfig } from '../../../../../../../plugins/index_pattern_management/public'; -import { MatchedIndex } from '../../types'; +import { MatchedItem } from '../../types'; import { IndexPatternManagmentContextValue } from '../../../../types'; interface StepIndexPatternProps { - allIndices: MatchedIndex[]; - isIncludingSystemIndices: boolean; + allIndices: MatchedItem[]; indexPatternCreationType: IndexPatternCreationConfig; - goToNextStep: (query: string) => void; + goToNextStep: (query: string, timestampField?: string) => void; initialQuery?: string; + showSystemIndices: boolean; } interface StepIndexPatternState { - partialMatchedIndices: MatchedIndex[]; - exactMatchedIndices: MatchedIndex[]; + partialMatchedIndices: MatchedItem[]; + exactMatchedIndices: MatchedItem[]; isLoadingIndices: boolean; existingIndexPatterns: string[]; indexPatternExists: boolean; @@ -61,8 +60,35 @@ interface StepIndexPatternState { appendedWildcard: boolean; showingIndexPatternQueryErrors: boolean; indexPatternName: string; + isIncludingSystemIndices: boolean; } +export const canPreselectTimeField = (indices: MatchedItem[]) => { + const preselectStatus = indices.reduce( + ( + { canPreselect, timeFieldName }: { canPreselect: boolean; timeFieldName?: string }, + matchedItem + ) => { + const dataStreamItem = matchedItem.item; + const dataStreamTimestampField = dataStreamItem.timestamp_field; + const isDataStream = !!dataStreamItem.timestamp_field; + const timestampFieldMatches = + timeFieldName === undefined || timeFieldName === dataStreamTimestampField; + + return { + canPreselect: canPreselect && isDataStream && timestampFieldMatches, + timeFieldName: dataStreamTimestampField || timeFieldName, + }; + }, + { + canPreselect: true, + timeFieldName: undefined, + } + ); + + return preselectStatus.canPreselect ? preselectStatus.timeFieldName : undefined; +}; + export class StepIndexPattern extends Component { static contextType = contextType; @@ -78,9 +104,9 @@ export class StepIndexPattern extends Component goToNextStep(query, canPreselectTimeField(indices))} isNextStepDisabled={isNextStepDisabled} + onChangeIncludingSystemIndices={this.onChangeIncludingSystemIndices} + isIncludingSystemIndices={isIncludingSystemIndices} + showSystemIndices={this.props.showSystemIndices} /> ); } + onChangeIncludingSystemIndices = (event: EuiSwitchEvent) => { + this.setState({ isIncludingSystemIndices: event.target.checked }, () => + this.fetchIndices(this.state.query) + ); + }; + render() { - const { isIncludingSystemIndices, allIndices } = this.props; - const { partialMatchedIndices, exactMatchedIndices } = this.state; + const { allIndices } = this.props; + const { partialMatchedIndices, exactMatchedIndices, isIncludingSystemIndices } = this.state; const matchedIndices = getMatchedIndices( allIndices, @@ -334,15 +372,15 @@ export class StepIndexPattern extends Component + <> {this.renderHeader(matchedIndices)} - + {this.renderLoadingState()} {this.renderIndexPatternExists()} {this.renderStatusMessage(matchedIndices)} - + {this.renderList(matchedIndices)} - + ); } } diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/__snapshots__/step_time_field.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/__snapshots__/step_time_field.test.tsx.snap index f865a1ddfd223..6cc92d20cfdcc 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/__snapshots__/step_time_field.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/__snapshots__/step_time_field.test.tsx.snap @@ -17,9 +17,7 @@ exports[`StepTimeField should enable the action button if the user decides to no `; exports[`StepTimeField should render "Custom index pattern ID already exists" when error is "Conflict" 1`] = ` - +
- + - + `; exports[`StepTimeField should render a loading state when creating the index pattern 1`] = ` - - + - - - - - +

- - - - +

+ +
+ + + +
`; exports[`StepTimeField should render a selected timeField 1`] = ` - +
- + - + `; exports[`StepTimeField should render advanced options 1`] = ` - +
- + - + `; exports[`StepTimeField should render advanced options with an index pattern id 1`] = ` - +
- + - + `; exports[`StepTimeField should render any error message 1`] = ` - +
- + - + `; exports[`StepTimeField should render normally 1`] = ` - +
- + - + `; exports[`StepTimeField should render timeFields 1`] = ` - +
- + - + `; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/__snapshots__/header.test.tsx.snap index 63008ec5b52e7..2ac243780b31d 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/__snapshots__/header.test.tsx.snap @@ -16,21 +16,10 @@ exports[`Header should render normally 1`] = ` - - - ki* - , - "indexPatternName": "ki*", - } - } - /> + + + ki* + `; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/header.tsx index 22e245f7ac137..c17b356e159f6 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/header/header.tsx @@ -39,15 +39,8 @@ export const Header: React.FC = ({ indexPattern, indexPatternName } - - {indexPattern}, - indexPatternName, - }} - /> + + {indexPattern} ); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/__snapshots__/time_field.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/__snapshots__/time_field.test.tsx.snap index 886a4ccad39cc..73277b1963626 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/__snapshots__/time_field.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/__snapshots__/time_field.test.tsx.snap @@ -2,55 +2,33 @@ exports[`TimeField should render a loading state 1`] = ` + +

+ +

+
+ -

- -

-

- -

- - } label={ - - - - - - - - - - + + } + labelAppend={ + } labelType="label" > @@ -73,62 +51,43 @@ exports[`TimeField should render a loading state 1`] = ` exports[`TimeField should render a selected time field 1`] = ` + +

+ +

+
+ -

- -

-

- -

- - } label={ - + } + labelAppend={ + - - - - - - - - - - - + + +
} labelType="label" > @@ -154,62 +113,43 @@ exports[`TimeField should render a selected time field 1`] = ` exports[`TimeField should render normally 1`] = ` + +

+ +

+
+ -

- -

-

- -

- - } label={ - + } + labelAppend={ + - - - - - - - - - - - + + +
} labelType="label" > diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/time_field.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/time_field.tsx index b4ed37118966b..7a3d72551f464 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/time_field.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/components/time_field/time_field.tsx @@ -24,8 +24,7 @@ import React from 'react'; import { EuiForm, EuiFormRow, - EuiFlexGroup, - EuiFlexItem, + EuiSpacer, EuiLink, EuiSelect, EuiText, @@ -54,77 +53,68 @@ export const TimeField: React.FC = ({ }) => ( {isVisible ? ( - - - - - - - - {isLoading ? ( - - ) : ( - + <> + +

+ +

+
+ + + } + labelAppend={ + isLoading ? ( + + ) : ( + + - )} -
- - } - helpText={ -
-

- -

-

- -

-
- } - > - {isLoading ? ( - - ) : ( - - )} -
+ + ) + } + > + {isLoading ? ( + + ) : ( + + )} + + ) : (

diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/step_time_field.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/step_time_field.tsx index 98ce22cd14227..5d33a08557fed 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/step_time_field.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_time_field/step_time_field.tsx @@ -22,10 +22,10 @@ import { EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiPanel, - EuiText, + EuiTitle, EuiSpacer, EuiLoadingSpinner, + EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ensureMinimumTime, extractTimeFields } from '../../lib'; @@ -43,6 +43,7 @@ interface StepTimeFieldProps { goToPreviousStep: () => void; createIndexPattern: (selectedTimeField: string | undefined, indexPatternId: string) => void; indexPatternCreationType: IndexPatternCreationConfig; + selectedTimeField?: string; } interface StepTimeFieldState { @@ -69,7 +70,7 @@ export class StepTimeField extends Component - - - - - - + + + +

- - - - +

+ + + + + + + ); } @@ -236,7 +242,7 @@ export class StepTimeField extends Component + <>
- + - + ); } } diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx index 111be41cfc53a..cd76ca09ccb74 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx @@ -19,10 +19,16 @@ import React, { ReactElement, Component } from 'react'; -import { EuiGlobalToastList, EuiGlobalToastListToast, EuiPanel } from '@elastic/eui'; +import { + EuiGlobalToastList, + EuiGlobalToastListToast, + EuiPageContent, + EuiHorizontalRule, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { DocLinksStart } from 'src/core/public'; import { StepIndexPattern } from './components/step_index_pattern'; import { StepTimeField } from './components/step_time_field'; import { Header } from './components/header'; @@ -31,21 +37,21 @@ import { EmptyState } from './components/empty_state'; import { context as contextType } from '../../../../kibana_react/public'; import { getCreateBreadcrumbs } from '../breadcrumbs'; -import { MAX_SEARCH_SIZE } from './constants'; import { ensureMinimumTime, getIndices } from './lib'; import { IndexPatternCreationConfig } from '../..'; import { IndexPatternManagmentContextValue } from '../../types'; -import { MatchedIndex } from './types'; +import { MatchedItem } from './types'; interface CreateIndexPatternWizardState { step: number; indexPattern: string; - allIndices: MatchedIndex[]; + allIndices: MatchedItem[]; remoteClustersExist: boolean; isInitiallyLoadingIndices: boolean; - isIncludingSystemIndices: boolean; toasts: EuiGlobalToastListToast[]; indexPatternCreationType: IndexPatternCreationConfig; + selectedTimeField?: string; + docLinks: DocLinksStart; } export class CreateIndexPatternWizard extends Component< @@ -69,9 +75,9 @@ export class CreateIndexPatternWizard extends Component< allIndices: [], remoteClustersExist: false, isInitiallyLoadingIndices: true, - isIncludingSystemIndices: false, toasts: [], indexPatternCreationType: context.services.indexPatternManagementStart.creation.getType(type), + docLinks: context.services.docLinks, }; } @@ -80,7 +86,7 @@ export class CreateIndexPatternWizard extends Component< } catchAndWarn = async ( - asyncFn: Promise, + asyncFn: Promise, errorValue: [] | string[], errorMsg: ReactElement ) => { @@ -102,12 +108,6 @@ export class CreateIndexPatternWizard extends Component< }; fetchData = async () => { - this.setState({ - allIndices: [], - isInitiallyLoadingIndices: true, - remoteClustersExist: false, - }); - const indicesFailMsg = ( + ).then((allIndices: MatchedItem[]) => this.setState({ allIndices, isInitiallyLoadingIndices: false }) ); this.catchAndWarn( // if we get an error from remote cluster query, supply fallback value that allows user entry. // ['a'] is fallback value - getIndices( - this.context.services.data.search.__LEGACY.esClient, - this.state.indexPatternCreationType, - `*:*`, - 1 - ), + getIndices(this.context.services.http, this.state.indexPatternCreationType, `*:*`, false), ['a'], clustersFailMsg - ).then((remoteIndices: string[] | MatchedIndex[]) => + ).then((remoteIndices: string[] | MatchedItem[]) => this.setState({ remoteClustersExist: !!remoteIndices.length }) ); }; @@ -189,7 +179,7 @@ export class CreateIndexPatternWizard extends Component< if (isConfirmed) { return history.push(`/patterns/${indexPatternId}`); } else { - return false; + return; } } @@ -201,31 +191,21 @@ export class CreateIndexPatternWizard extends Component< history.push(`/patterns/${createdId}`); }; - goToTimeFieldStep = (indexPattern: string) => { - this.setState({ step: 2, indexPattern }); + goToTimeFieldStep = (indexPattern: string, selectedTimeField?: string) => { + this.setState({ step: 2, indexPattern, selectedTimeField }); }; goToIndexPatternStep = () => { this.setState({ step: 1 }); }; - onChangeIncludingSystemIndices = () => { - this.setState((prevState) => ({ - isIncludingSystemIndices: !prevState.isIncludingSystemIndices, - })); - }; - renderHeader() { - const { isIncludingSystemIndices } = this.state; - return (
); } @@ -234,7 +214,6 @@ export class CreateIndexPatternWizard extends Component< const { allIndices, isInitiallyLoadingIndices, - isIncludingSystemIndices, step, indexPattern, remoteClustersExist, @@ -244,8 +223,8 @@ export class CreateIndexPatternWizard extends Component< return ; } - const hasDataIndices = allIndices.some(({ name }: MatchedIndex) => !name.startsWith('.')); - if (!hasDataIndices && !isIncludingSystemIndices && !remoteClustersExist) { + const hasDataIndices = allIndices.some(({ name }: MatchedItem) => !name.startsWith('.')); + if (!hasDataIndices && !remoteClustersExist) { return ( + + {header} + + + ); } if (step === 2) { return ( - + + {header} + + + ); } @@ -290,15 +282,11 @@ export class CreateIndexPatternWizard extends Component< }; render() { - const header = this.renderHeader(); const content = this.renderContent(); return ( - -
- {header} - {content} -
+ <> + {content} { @@ -306,7 +294,7 @@ export class CreateIndexPatternWizard extends Component< }} toastLifeTimeMs={6000} /> -
+ ); } } diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/__snapshots__/get_indices.test.ts.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/__snapshots__/get_indices.test.ts.snap new file mode 100644 index 0000000000000..99876383b4343 --- /dev/null +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/__snapshots__/get_indices.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getIndices response object to item array 1`] = ` +Array [ + Object { + "item": Object { + "attributes": Array [ + "frozen", + ], + "name": "frozen_index", + }, + "name": "frozen_index", + "tags": Array [ + Object { + "color": "default", + "key": "index", + "name": "Index", + }, + Object { + "color": "danger", + "key": "frozen", + "name": "Frozen", + }, + ], + }, + Object { + "item": Object { + "indices": Array [], + "name": "test_alias", + }, + "name": "test_alias", + "tags": Array [ + Object { + "color": "default", + "key": "alias", + "name": "Alias", + }, + ], + }, + Object { + "item": Object { + "backing_indices": Array [], + "name": "test_data_stream", + "timestamp_field": "test_timestamp_field", + }, + "name": "test_data_stream", + "tags": Array [ + Object { + "color": "primary", + "key": "data_stream", + "name": "Data stream", + }, + ], + }, + Object { + "item": Object { + "name": "test_index", + }, + "name": "test_index", + "tags": Array [ + Object { + "color": "default", + "key": "index", + "name": "Index", + }, + ], + }, +] +`; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.test.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.test.ts index b1faca8a04964..8e4dd37284333 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.test.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.test.ts @@ -17,66 +17,31 @@ * under the License. */ -import { getIndices } from './get_indices'; +import { getIndices, responseToItemArray } from './get_indices'; import { IndexPatternCreationConfig } from '../../../../../index_pattern_management/public'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LegacyApiCaller } from '../../../../../data/public/search/legacy'; +import { httpServiceMock } from '../../../../../../core/public/mocks'; +import { ResolveIndexResponseItemIndexAttrs } from '../types'; export const successfulResponse = { - hits: { - total: 1, - max_score: 0.0, - hits: [], - }, - aggregations: { - indices: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '1', - doc_count: 1, - }, - { - key: '2', - doc_count: 1, - }, - ], + indices: [ + { + name: 'remoteCluster1:bar-01', + attributes: ['open'], }, - }, -}; - -export const exceptionResponse = { - body: { - error: { - root_cause: [ - { - type: 'index_not_found_exception', - reason: 'no such index', - index_uuid: '_na_', - 'resource.type': 'index_or_alias', - 'resource.id': 't', - index: 't', - }, - ], - type: 'transport_exception', - reason: 'unable to communicate with remote cluster [cluster_one]', - caused_by: { - type: 'index_not_found_exception', - reason: 'no such index', - index_uuid: '_na_', - 'resource.type': 'index_or_alias', - 'resource.id': 't', - index: 't', - }, + ], + aliases: [ + { + name: 'f-alias', + indices: ['freeze-index', 'my-index'], }, - }, - status: 500, -}; - -export const errorResponse = { - statusCode: 400, - error: 'Bad Request', + ], + data_streams: [ + { + name: 'foo', + backing_indices: ['foo-000001'], + timestamp_field: '@timestamp', + }, + ], }; const mockIndexPatternCreationType = new IndexPatternCreationConfig({ @@ -87,81 +52,62 @@ const mockIndexPatternCreationType = new IndexPatternCreationConfig({ isBeta: false, }); -function esClientFactory(search: (params: any) => any): LegacyApiCaller { - return { - search, - msearch: () => ({ - abort: () => {}, - ...new Promise((resolve) => resolve({})), - }), - }; -} - -const es = esClientFactory(() => successfulResponse); +const http = httpServiceMock.createStartContract(); +http.get.mockResolvedValue(successfulResponse); describe('getIndices', () => { it('should work in a basic case', async () => { - const result = await getIndices(es, mockIndexPatternCreationType, 'kibana', 1); - expect(result.length).toBe(2); - expect(result[0].name).toBe('1'); - expect(result[1].name).toBe('2'); + const result = await getIndices(http, mockIndexPatternCreationType, 'kibana', false); + expect(result.length).toBe(3); + expect(result[0].name).toBe('f-alias'); + expect(result[1].name).toBe('foo'); }); it('should ignore ccs query-all', async () => { - expect((await getIndices(es, mockIndexPatternCreationType, '*:', 10)).length).toBe(0); + expect((await getIndices(http, mockIndexPatternCreationType, '*:', false)).length).toBe(0); }); it('should ignore a single comma', async () => { - expect((await getIndices(es, mockIndexPatternCreationType, ',', 10)).length).toBe(0); - expect((await getIndices(es, mockIndexPatternCreationType, ',*', 10)).length).toBe(0); - expect((await getIndices(es, mockIndexPatternCreationType, ',foobar', 10)).length).toBe(0); - }); - - it('should trim the input', async () => { - let index; - const esClient = esClientFactory( - jest.fn().mockImplementation((params) => { - index = params.index; - }) - ); - - await getIndices(esClient, mockIndexPatternCreationType, 'kibana ', 1); - expect(index).toBe('kibana'); + expect((await getIndices(http, mockIndexPatternCreationType, ',', false)).length).toBe(0); + expect((await getIndices(http, mockIndexPatternCreationType, ',*', false)).length).toBe(0); + expect((await getIndices(http, mockIndexPatternCreationType, ',foobar', false)).length).toBe(0); }); - it('should use the limit', async () => { - let limit; - const esClient = esClientFactory( - jest.fn().mockImplementation((params) => { - limit = params.body.aggs.indices.terms.size; - }) - ); - await getIndices(esClient, mockIndexPatternCreationType, 'kibana', 10); - expect(limit).toBe(10); + it('response object to item array', () => { + const result = { + indices: [ + { + name: 'test_index', + }, + { + name: 'frozen_index', + attributes: ['frozen' as ResolveIndexResponseItemIndexAttrs], + }, + ], + aliases: [ + { + name: 'test_alias', + indices: [], + }, + ], + data_streams: [ + { + name: 'test_data_stream', + backing_indices: [], + timestamp_field: 'test_timestamp_field', + }, + ], + }; + expect(responseToItemArray(result, mockIndexPatternCreationType)).toMatchSnapshot(); + expect(responseToItemArray({}, mockIndexPatternCreationType)).toEqual([]); }); describe('errors', () => { it('should handle errors gracefully', async () => { - const esClient = esClientFactory(() => errorResponse); - const result = await getIndices(esClient, mockIndexPatternCreationType, 'kibana', 1); - expect(result.length).toBe(0); - }); - - it('should throw exceptions', async () => { - const esClient = esClientFactory(() => { - throw new Error('Fail'); + http.get.mockImplementationOnce(() => { + throw new Error('Test error'); }); - - await expect( - getIndices(esClient, mockIndexPatternCreationType, 'kibana', 1) - ).rejects.toThrow(); - }); - - it('should handle index_not_found_exception errors gracefully', async () => { - const esClient = esClientFactory( - () => new Promise((resolve, reject) => reject(exceptionResponse)) - ); - const result = await getIndices(esClient, mockIndexPatternCreationType, 'kibana', 1); + const result = await getIndices(http, mockIndexPatternCreationType, 'kibana', false); expect(result.length).toBe(0); }); }); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.ts index 9f75dc39a654c..c6a11de1bc4fc 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_indices.ts @@ -17,17 +17,31 @@ * under the License. */ -import { get, sortBy } from 'lodash'; +import { sortBy } from 'lodash'; +import { HttpStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; import { IndexPatternCreationConfig } from '../../../../../index_pattern_management/public'; -import { DataPublicPluginStart } from '../../../../../data/public'; -import { MatchedIndex } from '../types'; +import { MatchedItem, ResolveIndexResponse, ResolveIndexResponseItemIndexAttrs } from '../types'; + +const aliasLabel = i18n.translate('indexPatternManagement.aliasLabel', { defaultMessage: 'Alias' }); +const dataStreamLabel = i18n.translate('indexPatternManagement.dataStreamLabel', { + defaultMessage: 'Data stream', +}); + +const indexLabel = i18n.translate('indexPatternManagement.indexLabel', { + defaultMessage: 'Index', +}); + +const frozenLabel = i18n.translate('indexPatternManagement.frozenLabel', { + defaultMessage: 'Frozen', +}); export async function getIndices( - es: DataPublicPluginStart['search']['__LEGACY']['esClient'], + http: HttpStart, indexPatternCreationType: IndexPatternCreationConfig, rawPattern: string, - limit: number -): Promise { + showAllIndices: boolean +): Promise { const pattern = rawPattern.trim(); // Searching for `*:` fails for CCS environments. The search request @@ -48,54 +62,58 @@ export async function getIndices( return []; } - // We need to always provide a limit and not rely on the default - if (!limit) { - throw new Error('`getIndices()` was called without the required `limit` parameter.'); - } - - const params = { - ignoreUnavailable: true, - index: pattern, - ignore: [404], - body: { - size: 0, // no hits - aggs: { - indices: { - terms: { - field: '_index', - size: limit, - }, - }, - }, - }, - }; + const query = showAllIndices ? { expand_wildcards: 'all' } : undefined; try { - const response = await es.search(params); - if (!response || response.error || !response.aggregations) { - return []; - } - - return sortBy( - response.aggregations.indices.buckets - .map((bucket: { key: string; doc_count: number }) => { - return bucket.key; - }) - .map((indexName: string) => { - return { - name: indexName, - tags: indexPatternCreationType.getIndexTags(indexName), - }; - }), - 'name' + const response = await http.get( + `/internal/index-pattern-management/resolve_index/${pattern}`, + { query } ); - } catch (err) { - const type = get(err, 'body.error.caused_by.type'); - if (type === 'index_not_found_exception') { - // This happens in a CSS environment when the controlling node returns a 500 even though the data - // nodes returned a 404. Remove this when/if this is handled: https://github.com/elastic/elasticsearch/issues/27461 + if (!response) { return []; } - throw err; + + return responseToItemArray(response, indexPatternCreationType); + } catch { + return []; } } + +export const responseToItemArray = ( + response: ResolveIndexResponse, + indexPatternCreationType: IndexPatternCreationConfig +): MatchedItem[] => { + const source: MatchedItem[] = []; + + (response.indices || []).forEach((index) => { + const tags: MatchedItem['tags'] = [{ key: 'index', name: indexLabel, color: 'default' }]; + const isFrozen = (index.attributes || []).includes(ResolveIndexResponseItemIndexAttrs.FROZEN); + + tags.push(...indexPatternCreationType.getIndexTags(index.name)); + if (isFrozen) { + tags.push({ name: frozenLabel, key: 'frozen', color: 'danger' }); + } + + source.push({ + name: index.name, + tags, + item: index, + }); + }); + (response.aliases || []).forEach((alias) => { + source.push({ + name: alias.name, + tags: [{ key: 'alias', name: aliasLabel, color: 'default' }], + item: alias, + }); + }); + (response.data_streams || []).forEach((dataStream) => { + source.push({ + name: dataStream.name, + tags: [{ key: 'data_stream', name: dataStreamLabel, color: 'primary' }], + item: dataStream, + }); + }); + + return sortBy(source, 'name'); +}; diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.test.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.test.ts index 65840aa64046d..c27eaa5ebc99e 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.test.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.test.ts @@ -18,7 +18,7 @@ */ import { getMatchedIndices } from './get_matched_indices'; -import { Tag } from '../types'; +import { Tag, MatchedItem } from '../types'; jest.mock('./../constants', () => ({ MAX_NUMBER_OF_MATCHING_INDICES: 6, @@ -32,18 +32,18 @@ const indices = [ { name: 'packetbeat', tags }, { name: 'metricbeat', tags }, { name: '.kibana', tags }, -]; +] as MatchedItem[]; const partialIndices = [ { name: 'kibana', tags }, { name: 'es', tags }, { name: '.kibana', tags }, -]; +] as MatchedItem[]; const exactIndices = [ { name: 'kibana', tags }, { name: '.kibana', tags }, -]; +] as MatchedItem[]; describe('getMatchedIndices', () => { it('should return all indices', () => { diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.ts index 7e2eeb17ab387..dbb166597152e 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/get_matched_indices.ts @@ -33,7 +33,7 @@ function isSystemIndex(index: string): boolean { return false; } -function filterSystemIndices(indices: MatchedIndex[], isIncludingSystemIndices: boolean) { +function filterSystemIndices(indices: MatchedItem[], isIncludingSystemIndices: boolean) { if (!indices) { return indices; } @@ -65,12 +65,12 @@ function filterSystemIndices(indices: MatchedIndex[], isIncludingSystemIndices: We call this `exact` matches because ES is telling us exactly what it matches */ -import { MatchedIndex } from '../types'; +import { MatchedItem } from '../types'; export function getMatchedIndices( - unfilteredAllIndices: MatchedIndex[], - unfilteredPartialMatchedIndices: MatchedIndex[], - unfilteredExactMatchedIndices: MatchedIndex[], + unfilteredAllIndices: MatchedItem[], + unfilteredPartialMatchedIndices: MatchedItem[], + unfilteredExactMatchedIndices: MatchedItem[], isIncludingSystemIndices: boolean = false ) { const allIndices = filterSystemIndices(unfilteredAllIndices, isIncludingSystemIndices); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/types.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/types.ts index 634bbd856ea86..b23924837ffb7 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/types.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/types.ts @@ -17,12 +17,54 @@ * under the License. */ -export interface MatchedIndex { +export interface MatchedItem { name: string; tags: Tag[]; + item: { + name: string; + backing_indices?: string[]; + timestamp_field?: string; + indices?: string[]; + aliases?: string[]; + attributes?: ResolveIndexResponseItemIndexAttrs[]; + data_stream?: string; + }; +} + +export interface ResolveIndexResponse { + indices?: ResolveIndexResponseItemIndex[]; + aliases?: ResolveIndexResponseItemAlias[]; + data_streams?: ResolveIndexResponseItemDataStream[]; +} + +export interface ResolveIndexResponseItem { + name: string; +} + +export interface ResolveIndexResponseItemDataStream extends ResolveIndexResponseItem { + backing_indices: string[]; + timestamp_field: string; +} + +export interface ResolveIndexResponseItemAlias extends ResolveIndexResponseItem { + indices: string[]; +} + +export interface ResolveIndexResponseItemIndex extends ResolveIndexResponseItem { + aliases?: string[]; + attributes?: ResolveIndexResponseItemIndexAttrs[]; + data_stream?: string; +} + +export enum ResolveIndexResponseItemIndexAttrs { + OPEN = 'open', + CLOSED = 'closed', + HIDDEN = 'hidden', + FROZEN = 'frozen', } export interface Tag { name: string; key: string; + color: string; } diff --git a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap index 6bc99c356592e..7a7545580d82a 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap @@ -836,7 +836,6 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` testlang , "painlessLink": , "scriptsInAggregation": Please familiarize yourself with - - + and with - - + before using scripted fields. diff --git a/src/plugins/index_pattern_management/public/mocks.ts b/src/plugins/index_pattern_management/public/mocks.ts index 93574cde7dc85..ec8100db42085 100644 --- a/src/plugins/index_pattern_management/public/mocks.ts +++ b/src/plugins/index_pattern_management/public/mocks.ts @@ -76,6 +76,13 @@ const createInstance = async () => { }; }; +const docLinks = { + links: { + indexPatterns: {}, + scriptedFields: {}, + }, +}; + const createIndexPatternManagmentContext = () => { const { chrome, @@ -84,7 +91,6 @@ const createIndexPatternManagmentContext = () => { uiSettings, notifications, overlays, - docLinks, } = coreMock.createStart(); const { http } = coreMock.createSetup(); const data = dataPluginMock.createStartContract(); diff --git a/src/plugins/index_pattern_management/public/service/creation/config.ts b/src/plugins/index_pattern_management/public/service/creation/config.ts index 95a91fd7594ca..04510b1d64e1e 100644 --- a/src/plugins/index_pattern_management/public/service/creation/config.ts +++ b/src/plugins/index_pattern_management/public/service/creation/config.ts @@ -18,7 +18,7 @@ */ import { i18n } from '@kbn/i18n'; -import { MatchedIndex } from '../../components/create_index_pattern_wizard/types'; +import { MatchedItem } from '../../components/create_index_pattern_wizard/types'; const indexPatternTypeName = i18n.translate( 'indexPatternManagement.editIndexPattern.createIndex.defaultTypeName', @@ -105,7 +105,7 @@ export class IndexPatternCreationConfig { return []; } - public checkIndicesForErrors(indices: MatchedIndex[]) { + public checkIndicesForErrors(indices: MatchedItem[]) { return undefined; } diff --git a/src/plugins/index_pattern_management/server/index.ts b/src/plugins/index_pattern_management/server/index.ts new file mode 100644 index 0000000000000..02a4631589832 --- /dev/null +++ b/src/plugins/index_pattern_management/server/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { IndexPatternManagementPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new IndexPatternManagementPlugin(initializerContext); +} diff --git a/src/plugins/index_pattern_management/server/plugin.ts b/src/plugins/index_pattern_management/server/plugin.ts new file mode 100644 index 0000000000000..ecca45cbcc453 --- /dev/null +++ b/src/plugins/index_pattern_management/server/plugin.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext, CoreSetup, Plugin } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; + +export class IndexPatternManagementPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + + router.get( + { + path: '/internal/index-pattern-management/resolve_index/{query}', + validate: { + params: schema.object({ + query: schema.string(), + }), + query: schema.object({ + expand_wildcards: schema.maybe( + schema.oneOf([ + schema.literal('all'), + schema.literal('open'), + schema.literal('closed'), + schema.literal('hidden'), + schema.literal('none'), + ]) + ), + }), + }, + }, + async (context, req, res) => { + const queryString = req.query.expand_wildcards + ? { expand_wildcards: req.query.expand_wildcards } + : null; + const result = await context.core.elasticsearch.legacy.client.callAsCurrentUser( + 'transport.request', + { + method: 'GET', + path: `/_resolve/index/${encodeURIComponent(req.params.query)}${ + queryString ? '?' + new URLSearchParams(queryString).toString() : '' + }`, + } + ); + return res.ok({ body: result }); + } + ); + } + + public start() {} + + public stop() {} +} diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 8209f3e1ac9d6..cb8b5a6ddc65f 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -22,6 +22,7 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); + const es = getService('legacyEs'); const PageObjects = getPageObjects(['settings', 'common']); describe('"Create Index Pattern" wizard', function () { @@ -48,5 +49,59 @@ export default function ({ getService, getPageObjects }) { expect(isEnabled).to.be.ok(); }); }); + + describe('data streams', () => { + it('can be an index pattern', async () => { + await es.transport.request({ + path: '/_index_template/generic-logs', + method: 'PUT', + body: { + index_patterns: ['logs-*', 'test_data_stream'], + template: { + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + }, + }, + }, + data_stream: { + timestamp_field: '@timestamp', + }, + }, + }); + + await es.transport.request({ + path: '/_data_stream/test_data_stream', + method: 'PUT', + }); + + await PageObjects.settings.createIndexPattern('test_data_stream', false); + + await es.transport.request({ + path: '/_data_stream/test_data_stream', + method: 'DELETE', + }); + }); + }); + + describe('index alias', () => { + it('can be an index pattern', async () => { + await es.transport.request({ + path: '/blogs/_doc', + method: 'POST', + body: { user: 'matt', message: 20 }, + }); + + await es.transport.request({ + path: '/_aliases', + method: 'POST', + body: { actions: [{ add: { index: 'blogs', alias: 'alias1' } }] }, + }); + + await PageObjects.settings.createIndexPattern('alias1', false); + }); + }); }); } diff --git a/x-pack/plugins/rollup/public/index_pattern_creation/rollup_index_pattern_creation_config.js b/x-pack/plugins/rollup/public/index_pattern_creation/rollup_index_pattern_creation_config.js index 4ff189d8f1be0..643cc3efb0136 100644 --- a/x-pack/plugins/rollup/public/index_pattern_creation/rollup_index_pattern_creation_config.js +++ b/x-pack/plugins/rollup/public/index_pattern_creation/rollup_index_pattern_creation_config.js @@ -100,6 +100,7 @@ export class RollupIndexPatternCreationConfig extends IndexPatternCreationConfig { key: this.type, name: rollupIndexPatternIndexLabel, + color: 'primary', }, ] : []; From a86110488bfb4ff3c476baf0932d2559926f1eda Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 8 Jul 2020 16:19:12 +0100 Subject: [PATCH 02/10] [ML] Fixing missing daily_model_snapshot_retention_after_days in job update schema (#71086) --- .../plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index 16eaab20fe8cb..196e17d0984f9 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -70,6 +70,7 @@ export const anomalyDetectionUpdateJobSchema = schema.object({ ), groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), model_snapshot_retention_days: schema.maybe(schema.number()), + daily_model_snapshot_retention_after_days: schema.maybe(schema.number()), }); export const analysisConfigSchema = schema.object({ From f044856038e2dbd33988c2aeacd3b2596b91a997 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 8 Jul 2020 17:21:00 +0200 Subject: [PATCH 03/10] Add new elasticsearch client (#69905) * add "@elastic/elasticsearch" to dependencies * first POC of new client * add logging * add generation script for client facade API and implementation * add back keepAlive * add exports from client * add new client mocks * add some doc * fix API usages * rename legacy client to legacy in service * rename currently unused config/client observable * wire new client to service & update mocks * fix mock type * export client types * add transport.request * more doc * migrate version_check to new client * fix default port logic * rename legacy client mocks * move legacy client mocks to legacy folder * start adding tests * add configure_client tests * add get_client_facade tests * bump client to 7.8 * add cluster_client tests * expose new client on internal contract only * revert using the new client for es version check * add service level test for new client * update generated API * Revert "rename legacy client mocks" This reverts commit e48f3ad6 * address some review comments * revert ts-expect-error from unowned files * move response mocks to mocks.ts * Remove generated facade, use ES Client directly * log queries even in case of error * nits * use direct properties instead of accessors * handle async closing of client * review nits * ElasticSearchClient -> ElasticsearchClient * add test for encoded querystring * adapt test file --- package.json | 2 +- .../client/client_config.test.ts | 483 ++++++++++++++++++ .../elasticsearch/client/client_config.ts | 158 ++++++ .../client/cluster_client.test.mocks.ts | 23 + .../client/cluster_client.test.ts | 376 ++++++++++++++ .../elasticsearch/client/cluster_client.ts | 113 ++++ .../client/configure_client.test.mocks.ts | 32 ++ .../client/configure_client.test.ts | 279 ++++++++++ .../elasticsearch/client/configure_client.ts | 65 +++ src/core/server/elasticsearch/client/index.ts | 24 + .../server/elasticsearch/client/mocks.test.ts | 60 +++ src/core/server/elasticsearch/client/mocks.ts | 148 ++++++ .../client/scoped_cluster_client.test.ts | 41 ++ .../client/scoped_cluster_client.ts | 49 ++ src/core/server/elasticsearch/client/types.ts | 42 ++ .../elasticsearch_service.mock.ts | 28 +- .../elasticsearch_service.test.mocks.ts | 5 +- .../elasticsearch_service.test.ts | 195 +++++-- .../elasticsearch/elasticsearch_service.ts | 184 +++---- src/core/server/elasticsearch/index.ts | 8 + src/core/server/elasticsearch/types.ts | 60 ++- .../version_check/ensure_es_version.ts | 2 +- src/core/server/internal_types.ts | 7 +- src/core/server/mocks.ts | 2 +- src/core/server/plugins/plugin_context.ts | 4 +- src/core/server/server.api.md | 5 + .../api_integration/apis/fleet/agents/acks.ts | 2 +- .../apis/fleet/agents/checkin.ts | 2 +- .../apis/fleet/agents/enroll.ts | 2 +- .../upgrade_assistant/status.ts | 3 +- yarn.lock | 16 + 31 files changed, 2216 insertions(+), 204 deletions(-) create mode 100644 src/core/server/elasticsearch/client/client_config.test.ts create mode 100644 src/core/server/elasticsearch/client/client_config.ts create mode 100644 src/core/server/elasticsearch/client/cluster_client.test.mocks.ts create mode 100644 src/core/server/elasticsearch/client/cluster_client.test.ts create mode 100644 src/core/server/elasticsearch/client/cluster_client.ts create mode 100644 src/core/server/elasticsearch/client/configure_client.test.mocks.ts create mode 100644 src/core/server/elasticsearch/client/configure_client.test.ts create mode 100644 src/core/server/elasticsearch/client/configure_client.ts create mode 100644 src/core/server/elasticsearch/client/index.ts create mode 100644 src/core/server/elasticsearch/client/mocks.test.ts create mode 100644 src/core/server/elasticsearch/client/mocks.ts create mode 100644 src/core/server/elasticsearch/client/scoped_cluster_client.test.ts create mode 100644 src/core/server/elasticsearch/client/scoped_cluster_client.ts create mode 100644 src/core/server/elasticsearch/client/types.ts diff --git a/package.json b/package.json index 6178bb07067d7..1a497a2ec8b10 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@elastic/apm-rum": "^5.2.0", "@elastic/charts": "19.8.1", "@elastic/datemath": "5.0.3", + "@elastic/elasticsearch": "7.8.0", "@elastic/ems-client": "7.9.3", "@elastic/eui": "24.1.0", "@elastic/filesaver": "1.1.2", @@ -294,7 +295,6 @@ "devDependencies": { "@babel/parser": "^7.10.2", "@babel/types": "^7.10.2", - "@elastic/elasticsearch": "^7.4.0", "@elastic/eslint-config-kibana": "0.15.0", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/src/core/server/elasticsearch/client/client_config.test.ts b/src/core/server/elasticsearch/client/client_config.test.ts new file mode 100644 index 0000000000000..675d8840e7118 --- /dev/null +++ b/src/core/server/elasticsearch/client/client_config.test.ts @@ -0,0 +1,483 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { duration } from 'moment'; +import { ElasticsearchClientConfig, parseClientOptions } from './client_config'; + +const createConfig = ( + parts: Partial = {} +): ElasticsearchClientConfig => { + return { + customHeaders: {}, + logQueries: false, + sniffOnStart: false, + sniffOnConnectionFault: false, + sniffInterval: false, + requestHeadersWhitelist: ['authorization'], + hosts: ['http://localhost:80'], + ...parts, + }; +}; + +describe('parseClientOptions', () => { + describe('basic options', () => { + it('`customHeaders` option', () => { + const config = createConfig({ + customHeaders: { + foo: 'bar', + hello: 'dolly', + }, + }); + + expect(parseClientOptions(config, false)).toEqual( + expect.objectContaining({ + headers: { + foo: 'bar', + hello: 'dolly', + }, + }) + ); + }); + + it('`keepAlive` option', () => { + expect(parseClientOptions(createConfig({ keepAlive: true }), false)).toEqual( + expect.objectContaining({ agent: { keepAlive: true } }) + ); + expect(parseClientOptions(createConfig({ keepAlive: false }), false).agent).toBeUndefined(); + }); + + it('`sniffOnStart` options', () => { + expect( + parseClientOptions( + createConfig({ + sniffOnStart: true, + }), + false + ).sniffOnStart + ).toEqual(true); + + expect( + parseClientOptions( + createConfig({ + sniffOnStart: false, + }), + false + ).sniffOnStart + ).toEqual(false); + }); + it('`sniffOnConnectionFault` options', () => { + expect( + parseClientOptions( + createConfig({ + sniffOnConnectionFault: true, + }), + false + ).sniffOnConnectionFault + ).toEqual(true); + + expect( + parseClientOptions( + createConfig({ + sniffOnConnectionFault: false, + }), + false + ).sniffOnConnectionFault + ).toEqual(false); + }); + it('`sniffInterval` options', () => { + expect( + parseClientOptions( + createConfig({ + sniffInterval: false, + }), + false + ).sniffInterval + ).toEqual(false); + + expect( + parseClientOptions( + createConfig({ + sniffInterval: duration(100, 'ms'), + }), + false + ).sniffInterval + ).toEqual(100); + }); + + it('`hosts` option', () => { + const options = parseClientOptions( + createConfig({ + hosts: ['http://node-A:9200', 'http://node-B', 'https://node-C'], + }), + false + ); + + expect(options.nodes).toMatchInlineSnapshot(` + Array [ + Object { + "url": "http://node-a:9200/", + }, + Object { + "url": "http://node-b/", + }, + Object { + "url": "https://node-c/", + }, + ] + `); + }); + }); + + describe('authorization', () => { + describe('when `scoped` is false', () => { + it('adds the `auth` option if both `username` and `password` are set', () => { + expect( + parseClientOptions( + createConfig({ + username: 'user', + }), + false + ).auth + ).toBeUndefined(); + + expect( + parseClientOptions( + createConfig({ + password: 'pass', + }), + false + ).auth + ).toBeUndefined(); + + expect( + parseClientOptions( + createConfig({ + username: 'user', + password: 'pass', + }), + false + ) + ).toEqual( + expect.objectContaining({ + auth: { + username: 'user', + password: 'pass', + }, + }) + ); + }); + + it('adds auth to the nodes if both `username` and `password` are set', () => { + let options = parseClientOptions( + createConfig({ + username: 'user', + hosts: ['http://node-A:9200'], + }), + false + ); + expect(options.nodes).toMatchInlineSnapshot(` + Array [ + Object { + "url": "http://node-a:9200/", + }, + ] + `); + + options = parseClientOptions( + createConfig({ + password: 'pass', + hosts: ['http://node-A:9200'], + }), + false + ); + expect(options.nodes).toMatchInlineSnapshot(` + Array [ + Object { + "url": "http://node-a:9200/", + }, + ] + `); + + options = parseClientOptions( + createConfig({ + username: 'user', + password: 'pass', + hosts: ['http://node-A:9200'], + }), + false + ); + expect(options.nodes).toMatchInlineSnapshot(` + Array [ + Object { + "url": "http://user:pass@node-a:9200/", + }, + ] + `); + }); + }); + describe('when `scoped` is true', () => { + it('does not add the `auth` option even if both `username` and `password` are set', () => { + expect( + parseClientOptions( + createConfig({ + username: 'user', + password: 'pass', + }), + true + ).auth + ).toBeUndefined(); + }); + + it('does not add auth to the nodes even if both `username` and `password` are set', () => { + const options = parseClientOptions( + createConfig({ + username: 'user', + password: 'pass', + hosts: ['http://node-A:9200'], + }), + true + ); + expect(options.nodes).toMatchInlineSnapshot(` + Array [ + Object { + "url": "http://node-a:9200/", + }, + ] + `); + }); + }); + }); + + describe('ssl config', () => { + it('does not generate ssl option is ssl config is not set', () => { + expect(parseClientOptions(createConfig({}), false).ssl).toBeUndefined(); + expect(parseClientOptions(createConfig({}), true).ssl).toBeUndefined(); + }); + + it('handles the `certificateAuthorities` option', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { verificationMode: 'full', certificateAuthorities: ['content-of-ca-path'] }, + }), + false + ).ssl!.ca + ).toEqual(['content-of-ca-path']); + expect( + parseClientOptions( + createConfig({ + ssl: { verificationMode: 'full', certificateAuthorities: ['content-of-ca-path'] }, + }), + true + ).ssl!.ca + ).toEqual(['content-of-ca-path']); + }); + + describe('verificationMode', () => { + it('handles `none` value', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'none', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "rejectUnauthorized": false, + } + `); + }); + it('handles `certificate` value', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'certificate', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "checkServerIdentity": [Function], + "rejectUnauthorized": true, + } + `); + }); + it('handles `full` value', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "rejectUnauthorized": true, + } + `); + }); + it('throws for invalid values', () => { + expect( + () => + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'unknown' as any, + }, + }), + false + ).ssl + ).toThrowErrorMatchingInlineSnapshot(`"Unknown ssl verificationMode: unknown"`); + }); + it('throws for undefined values', () => { + expect( + () => + parseClientOptions( + createConfig({ + ssl: { + verificationMode: undefined as any, + }, + }), + false + ).ssl + ).toThrowErrorMatchingInlineSnapshot(`"Unknown ssl verificationMode: undefined"`); + }); + }); + + describe('`certificate`, `key` and `passphrase`', () => { + it('are not added if `key` is not present', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + certificate: 'content-of-cert', + keyPassphrase: 'passphrase', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "rejectUnauthorized": true, + } + `); + }); + + it('are not added if `certificate` is not present', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + key: 'content-of-key', + keyPassphrase: 'passphrase', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "rejectUnauthorized": true, + } + `); + }); + + it('are added if `key` and `certificate` are present and `scoped` is false', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + key: 'content-of-key', + certificate: 'content-of-cert', + keyPassphrase: 'passphrase', + }, + }), + false + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "cert": "content-of-cert", + "key": "content-of-key", + "passphrase": "passphrase", + "rejectUnauthorized": true, + } + `); + }); + + it('are not added if `scoped` is true unless `alwaysPresentCertificate` is true', () => { + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + key: 'content-of-key', + certificate: 'content-of-cert', + keyPassphrase: 'passphrase', + }, + }), + true + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "rejectUnauthorized": true, + } + `); + + expect( + parseClientOptions( + createConfig({ + ssl: { + verificationMode: 'full', + key: 'content-of-key', + certificate: 'content-of-cert', + keyPassphrase: 'passphrase', + alwaysPresentCertificate: true, + }, + }), + true + ).ssl + ).toMatchInlineSnapshot(` + Object { + "ca": undefined, + "cert": "content-of-cert", + "key": "content-of-key", + "passphrase": "passphrase", + "rejectUnauthorized": true, + } + `); + }); + }); + }); +}); diff --git a/src/core/server/elasticsearch/client/client_config.ts b/src/core/server/elasticsearch/client/client_config.ts new file mode 100644 index 0000000000000..f365ca331cfea --- /dev/null +++ b/src/core/server/elasticsearch/client/client_config.ts @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ConnectionOptions as TlsConnectionOptions } from 'tls'; +import { URL } from 'url'; +import { Duration } from 'moment'; +import { ClientOptions, NodeOptions } from '@elastic/elasticsearch'; +import { ElasticsearchConfig } from '../elasticsearch_config'; + +/** + * Configuration options to be used to create a {@link IClusterClient | cluster client} using the + * {@link ElasticsearchServiceStart.createClient | createClient API} + * + * @public + */ +export type ElasticsearchClientConfig = Pick< + ElasticsearchConfig, + | 'customHeaders' + | 'logQueries' + | 'sniffOnStart' + | 'sniffOnConnectionFault' + | 'requestHeadersWhitelist' + | 'sniffInterval' + | 'hosts' + | 'username' + | 'password' +> & { + pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout']; + requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; + ssl?: Partial; + keepAlive?: boolean; +}; + +/** + * Parse the client options from given client config and `scoped` flag. + * + * @param config The config to generate the client options from. + * @param scoped if true, will adapt the configuration to be used by a scoped client + * (will remove basic auth and ssl certificates) + */ +export function parseClientOptions( + config: ElasticsearchClientConfig, + scoped: boolean +): ClientOptions { + const clientOptions: ClientOptions = { + sniffOnStart: config.sniffOnStart, + sniffOnConnectionFault: config.sniffOnConnectionFault, + headers: config.customHeaders, + }; + + if (config.pingTimeout != null) { + clientOptions.pingTimeout = getDurationAsMs(config.pingTimeout); + } + if (config.requestTimeout != null) { + clientOptions.requestTimeout = getDurationAsMs(config.requestTimeout); + } + if (config.sniffInterval != null) { + clientOptions.sniffInterval = + typeof config.sniffInterval === 'boolean' + ? config.sniffInterval + : getDurationAsMs(config.sniffInterval); + } + if (config.keepAlive) { + clientOptions.agent = { + keepAlive: config.keepAlive, + }; + } + + if (config.username && config.password && !scoped) { + clientOptions.auth = { + username: config.username, + password: config.password, + }; + } + + clientOptions.nodes = config.hosts.map((host) => convertHost(host, !scoped, config)); + + if (config.ssl) { + clientOptions.ssl = generateSslConfig( + config.ssl, + scoped && !config.ssl.alwaysPresentCertificate + ); + } + + return clientOptions; +} + +const generateSslConfig = ( + sslConfig: Required['ssl'], + ignoreCertAndKey: boolean +): TlsConnectionOptions => { + const ssl: TlsConnectionOptions = { + ca: sslConfig.certificateAuthorities, + }; + + const verificationMode = sslConfig.verificationMode; + switch (verificationMode) { + case 'none': + ssl.rejectUnauthorized = false; + break; + case 'certificate': + ssl.rejectUnauthorized = true; + // by default, NodeJS is checking the server identify + ssl.checkServerIdentity = () => undefined; + break; + case 'full': + ssl.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + // Add client certificate and key if required by elasticsearch + if (!ignoreCertAndKey && sslConfig.certificate && sslConfig.key) { + ssl.cert = sslConfig.certificate; + ssl.key = sslConfig.key; + ssl.passphrase = sslConfig.keyPassphrase; + } + + return ssl; +}; + +const convertHost = ( + host: string, + needAuth: boolean, + { username, password }: ElasticsearchClientConfig +): NodeOptions => { + const url = new URL(host); + const isHTTPS = url.protocol === 'https:'; + url.port = url.port || (isHTTPS ? '443' : '80'); + if (needAuth && username && password) { + url.username = username; + url.password = password; + } + + return { + url, + }; +}; + +const getDurationAsMs = (duration: number | Duration) => + typeof duration === 'number' ? duration : duration.asMilliseconds(); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts b/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts new file mode 100644 index 0000000000000..e08c0d55b4551 --- /dev/null +++ b/src/core/server/elasticsearch/client/cluster_client.test.mocks.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const configureClientMock = jest.fn(); +jest.doMock('./configure_client', () => ({ + configureClient: configureClientMock, +})); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts new file mode 100644 index 0000000000000..85517b80745f1 --- /dev/null +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -0,0 +1,376 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { configureClientMock } from './cluster_client.test.mocks'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { httpServerMock } from '../../http/http_server.mocks'; +import { GetAuthHeaders } from '../../http'; +import { elasticsearchClientMock } from './mocks'; +import { ClusterClient } from './cluster_client'; +import { ElasticsearchClientConfig } from './client_config'; + +const createConfig = ( + parts: Partial = {} +): ElasticsearchClientConfig => { + return { + logQueries: false, + sniffOnStart: false, + sniffOnConnectionFault: false, + sniffInterval: false, + requestHeadersWhitelist: ['authorization'], + customHeaders: {}, + hosts: ['http://localhost'], + ...parts, + }; +}; + +describe('ClusterClient', () => { + let logger: ReturnType; + let getAuthHeaders: jest.MockedFunction; + let internalClient: ReturnType; + let scopedClient: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + internalClient = elasticsearchClientMock.createInternalClient(); + scopedClient = elasticsearchClientMock.createInternalClient(); + getAuthHeaders = jest.fn().mockImplementation(() => ({ + authorization: 'auth', + foo: 'bar', + })); + + configureClientMock.mockImplementation((config, { scoped = false }) => { + return scoped ? scopedClient : internalClient; + }); + }); + + afterEach(() => { + configureClientMock.mockReset(); + }); + + it('creates a single internal and scoped client during initialization', () => { + const config = createConfig(); + + new ClusterClient(config, logger, getAuthHeaders); + + expect(configureClientMock).toHaveBeenCalledTimes(2); + expect(configureClientMock).toHaveBeenCalledWith(config, { logger }); + expect(configureClientMock).toHaveBeenCalledWith(config, { logger, scoped: true }); + }); + + describe('#asInternalUser', () => { + it('returns the internal client', () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + + expect(clusterClient.asInternalUser).toBe(internalClient); + }); + }); + + describe('#asScoped', () => { + it('returns a scoped cluster client bound to the request', () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest(); + + const scopedClusterClient = clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ headers: expect.any(Object) }); + + expect(scopedClusterClient.asInternalUser).toBe(clusterClient.asInternalUser); + expect(scopedClusterClient.asCurrentUser).toBe(scopedClient.child.mock.results[0].value); + }); + + it('returns a distinct scoped cluster client on each call', () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest(); + + const scopedClusterClient1 = clusterClient.asScoped(request); + const scopedClusterClient2 = clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(2); + + expect(scopedClusterClient1).not.toBe(scopedClusterClient2); + expect(scopedClusterClient1.asInternalUser).toBe(scopedClusterClient2.asInternalUser); + }); + + it('creates a scoped client with filtered request headers', () => { + const config = createConfig({ + requestHeadersWhitelist: ['foo'], + }); + getAuthHeaders.mockReturnValue({}); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({ + headers: { + foo: 'bar', + hello: 'dolly', + }, + }); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { foo: 'bar' }, + }); + }); + + it('creates a scoped facade with filtered auth headers', () => { + const config = createConfig({ + requestHeadersWhitelist: ['authorization'], + }); + getAuthHeaders.mockReturnValue({ + authorization: 'auth', + other: 'nope', + }); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({}); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { authorization: 'auth' }, + }); + }); + + it('respects auth headers precedence', () => { + const config = createConfig({ + requestHeadersWhitelist: ['authorization'], + }); + getAuthHeaders.mockReturnValue({ + authorization: 'auth', + other: 'nope', + }); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({ + headers: { + authorization: 'override', + }, + }); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { authorization: 'auth' }, + }); + }); + + it('includes the `customHeaders` from the config without filtering them', () => { + const config = createConfig({ + customHeaders: { + foo: 'bar', + hello: 'dolly', + }, + requestHeadersWhitelist: ['authorization'], + }); + getAuthHeaders.mockReturnValue({}); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({}); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { + foo: 'bar', + hello: 'dolly', + }, + }); + }); + + it('respect the precedence of auth headers over config headers', () => { + const config = createConfig({ + customHeaders: { + foo: 'config', + hello: 'dolly', + }, + requestHeadersWhitelist: ['foo'], + }); + getAuthHeaders.mockReturnValue({ + foo: 'auth', + }); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({}); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { + foo: 'auth', + hello: 'dolly', + }, + }); + }); + + it('respect the precedence of request headers over config headers', () => { + const config = createConfig({ + customHeaders: { + foo: 'config', + hello: 'dolly', + }, + requestHeadersWhitelist: ['foo'], + }); + getAuthHeaders.mockReturnValue({}); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = httpServerMock.createKibanaRequest({ + headers: { foo: 'request' }, + }); + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { + foo: 'request', + hello: 'dolly', + }, + }); + }); + + it('filter headers when called with a `FakeRequest`', () => { + const config = createConfig({ + requestHeadersWhitelist: ['authorization'], + }); + getAuthHeaders.mockReturnValue({}); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = { + headers: { + authorization: 'auth', + hello: 'dolly', + }, + }; + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { authorization: 'auth' }, + }); + }); + + it('does not add auth headers when called with a `FakeRequest`', () => { + const config = createConfig({ + requestHeadersWhitelist: ['authorization', 'foo'], + }); + getAuthHeaders.mockReturnValue({ + authorization: 'auth', + }); + + const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const request = { + headers: { + foo: 'bar', + hello: 'dolly', + }, + }; + + clusterClient.asScoped(request); + + expect(scopedClient.child).toHaveBeenCalledTimes(1); + expect(scopedClient.child).toHaveBeenCalledWith({ + headers: { foo: 'bar' }, + }); + }); + }); + + describe('#close', () => { + it('closes both underlying clients', async () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + + await clusterClient.close(); + + expect(internalClient.close).toHaveBeenCalledTimes(1); + expect(scopedClient.close).toHaveBeenCalledTimes(1); + }); + + it('waits for both clients to close', async (done) => { + expect.assertions(4); + + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + + let internalClientClosed = false; + let scopedClientClosed = false; + let clusterClientClosed = false; + + let closeInternalClient: () => void; + let closeScopedClient: () => void; + + internalClient.close.mockReturnValue( + new Promise((resolve) => { + closeInternalClient = resolve; + }).then(() => { + expect(clusterClientClosed).toBe(false); + internalClientClosed = true; + }) + ); + scopedClient.close.mockReturnValue( + new Promise((resolve) => { + closeScopedClient = resolve; + }).then(() => { + expect(clusterClientClosed).toBe(false); + scopedClientClosed = true; + }) + ); + + clusterClient.close().then(() => { + clusterClientClosed = true; + expect(internalClientClosed).toBe(true); + expect(scopedClientClosed).toBe(true); + done(); + }); + + closeInternalClient!(); + closeScopedClient!(); + }); + + it('return a rejected promise is any client rejects', async () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + + internalClient.close.mockRejectedValue(new Error('error closing client')); + + expect(clusterClient.close()).rejects.toThrowErrorMatchingInlineSnapshot( + `"error closing client"` + ); + }); + + it('does nothing after the first call', async () => { + const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + + await clusterClient.close(); + + expect(internalClient.close).toHaveBeenCalledTimes(1); + expect(scopedClient.close).toHaveBeenCalledTimes(1); + + await clusterClient.close(); + await clusterClient.close(); + + expect(internalClient.close).toHaveBeenCalledTimes(1); + expect(scopedClient.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/core/server/elasticsearch/client/cluster_client.ts b/src/core/server/elasticsearch/client/cluster_client.ts new file mode 100644 index 0000000000000..d9a0e6fe3f238 --- /dev/null +++ b/src/core/server/elasticsearch/client/cluster_client.ts @@ -0,0 +1,113 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Client } from '@elastic/elasticsearch'; +import { Logger } from '../../logging'; +import { GetAuthHeaders, isRealRequest, Headers } from '../../http'; +import { ensureRawRequest, filterHeaders } from '../../http/router'; +import { ScopeableRequest } from '../types'; +import { ElasticsearchClient } from './types'; +import { configureClient } from './configure_client'; +import { ElasticsearchClientConfig } from './client_config'; +import { ScopedClusterClient, IScopedClusterClient } from './scoped_cluster_client'; + +const noop = () => undefined; + +/** + * Represents an Elasticsearch cluster API client created by the platform. + * It allows to call API on behalf of the internal Kibana user and + * the actual user that is derived from the request headers (via `asScoped(...)`). + * + * @public + **/ +export interface IClusterClient { + /** + * A {@link ElasticsearchClient | client} to be used to query the ES cluster on behalf of the Kibana internal user + */ + readonly asInternalUser: ElasticsearchClient; + /** + * Creates a {@link IScopedClusterClient | scoped cluster client} bound to given {@link ScopeableRequest | request} + */ + asScoped: (request: ScopeableRequest) => IScopedClusterClient; +} + +/** + * See {@link IClusterClient} + * + * @public + */ +export interface ICustomClusterClient extends IClusterClient { + /** + * Closes the cluster client. After that client cannot be used and one should + * create a new client instance to be able to interact with Elasticsearch API. + */ + close: () => Promise; +} + +/** @internal **/ +export class ClusterClient implements ICustomClusterClient { + public readonly asInternalUser: Client; + private readonly rootScopedClient: Client; + + private isClosed = false; + + constructor( + private readonly config: ElasticsearchClientConfig, + logger: Logger, + private readonly getAuthHeaders: GetAuthHeaders = noop + ) { + this.asInternalUser = configureClient(config, { logger }); + this.rootScopedClient = configureClient(config, { logger, scoped: true }); + } + + asScoped(request: ScopeableRequest) { + const scopedHeaders = this.getScopedHeaders(request); + const scopedClient = this.rootScopedClient.child({ + headers: scopedHeaders, + }); + return new ScopedClusterClient(this.asInternalUser, scopedClient); + } + + public async close() { + if (this.isClosed) { + return; + } + this.isClosed = true; + await Promise.all([this.asInternalUser.close(), this.rootScopedClient.close()]); + } + + private getScopedHeaders(request: ScopeableRequest): Headers { + let scopedHeaders: Headers; + if (isRealRequest(request)) { + const authHeaders = this.getAuthHeaders(request); + const requestHeaders = ensureRawRequest(request).headers; + scopedHeaders = filterHeaders( + { ...requestHeaders, ...authHeaders }, + this.config.requestHeadersWhitelist + ); + } else { + scopedHeaders = filterHeaders(request?.headers ?? {}, this.config.requestHeadersWhitelist); + } + + return { + ...this.config.customHeaders, + ...scopedHeaders, + }; + } +} diff --git a/src/core/server/elasticsearch/client/configure_client.test.mocks.ts b/src/core/server/elasticsearch/client/configure_client.test.mocks.ts new file mode 100644 index 0000000000000..0a74f57120fb0 --- /dev/null +++ b/src/core/server/elasticsearch/client/configure_client.test.mocks.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const parseClientOptionsMock = jest.fn(); +jest.doMock('./client_config', () => ({ + parseClientOptions: parseClientOptionsMock, +})); + +export const ClientMock = jest.fn(); +jest.doMock('@elastic/elasticsearch', () => { + const actual = jest.requireActual('@elastic/elasticsearch'); + return { + ...actual, + Client: ClientMock, + }; +}); diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts new file mode 100644 index 0000000000000..32da142764a78 --- /dev/null +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -0,0 +1,279 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RequestEvent, errors } from '@elastic/elasticsearch'; +import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; + +import { parseClientOptionsMock, ClientMock } from './configure_client.test.mocks'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; +import EventEmitter from 'events'; +import type { ElasticsearchClientConfig } from './client_config'; +import { configureClient } from './configure_client'; + +const createFakeConfig = ( + parts: Partial = {} +): ElasticsearchClientConfig => { + return ({ + type: 'fake-config', + ...parts, + } as unknown) as ElasticsearchClientConfig; +}; + +const createFakeClient = () => { + const client = new EventEmitter(); + jest.spyOn(client, 'on'); + return client; +}; + +const createApiResponse = ({ + body, + statusCode = 200, + headers = {}, + warnings = [], + params, +}: { + body: T; + statusCode?: number; + headers?: Record; + warnings?: string[]; + params?: TransportRequestParams; +}): RequestEvent => { + return { + body, + statusCode, + headers, + warnings, + meta: { + request: { + params: params!, + } as any, + } as any, + }; +}; + +describe('configureClient', () => { + let logger: ReturnType; + let config: ElasticsearchClientConfig; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + config = createFakeConfig(); + parseClientOptionsMock.mockReturnValue({}); + ClientMock.mockImplementation(() => createFakeClient()); + }); + + afterEach(() => { + parseClientOptionsMock.mockReset(); + ClientMock.mockReset(); + }); + + it('calls `parseClientOptions` with the correct parameters', () => { + configureClient(config, { logger, scoped: false }); + + expect(parseClientOptionsMock).toHaveBeenCalledTimes(1); + expect(parseClientOptionsMock).toHaveBeenCalledWith(config, false); + + parseClientOptionsMock.mockClear(); + + configureClient(config, { logger, scoped: true }); + + expect(parseClientOptionsMock).toHaveBeenCalledTimes(1); + expect(parseClientOptionsMock).toHaveBeenCalledWith(config, true); + }); + + it('constructs a client using the options returned by `parseClientOptions`', () => { + const parsedOptions = { + nodes: ['http://localhost'], + }; + parseClientOptionsMock.mockReturnValue(parsedOptions); + + const client = configureClient(config, { logger, scoped: false }); + + expect(ClientMock).toHaveBeenCalledTimes(1); + expect(ClientMock).toHaveBeenCalledWith(parsedOptions); + expect(client).toBe(ClientMock.mock.results[0].value); + }); + + it('listens to client on `response` events', () => { + const client = configureClient(config, { logger, scoped: false }); + + expect(client.on).toHaveBeenCalledTimes(1); + expect(client.on).toHaveBeenCalledWith('response', expect.any(Function)); + }); + + describe('Client logging', () => { + it('logs error when the client emits an error', () => { + const client = configureClient(config, { logger, scoped: false }); + + const response = createApiResponse({ + body: { + error: { + type: 'error message', + }, + }, + }); + client.emit('response', new errors.ResponseError(response), null); + client.emit('response', new Error('some error'), null); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "ResponseError: error message", + ], + Array [ + "Error: some error", + ], + ] + `); + }); + + it('logs each queries if `logQueries` is true', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + }, + }); + + client.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo + hello=dolly", + Object { + "tags": Array [ + "query", + ], + }, + ], + ] + `); + }); + + it('properly encode queries', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { city: 'Münich' }, + }, + }); + + client.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo + city=M%C3%BCnich", + Object { + "tags": Array [ + "query", + ], + }, + ], + ] + `); + }); + + it('logs queries even in case of errors if `logQueries` is true', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + statusCode: 500, + body: { + error: { + type: 'internal server error', + }, + }, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + }, + }); + client.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "500 + GET /foo + hello=dolly", + Object { + "tags": Array [ + "query", + ], + }, + ], + ] + `); + }); + + it('does not log queries if `logQueries` is false', () => { + const client = configureClient( + createFakeConfig({ + logQueries: false, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + }, + }); + + client.emit('response', null, response); + + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts new file mode 100644 index 0000000000000..5377f8ca1b070 --- /dev/null +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { stringify } from 'querystring'; +import { Client } from '@elastic/elasticsearch'; +import { Logger } from '../../logging'; +import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; + +export const configureClient = ( + config: ElasticsearchClientConfig, + { logger, scoped = false }: { logger: Logger; scoped?: boolean } +): Client => { + const clientOptions = parseClientOptions(config, scoped); + + const client = new Client(clientOptions); + addLogging(client, logger, config.logQueries); + + return client; +}; + +const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { + client.on('response', (err, event) => { + if (err) { + logger.error(`${err.name}: ${err.message}`); + } + if (event && logQueries) { + const params = event.meta.request.params; + + // definition is wrong, `params.querystring` can be either a string or an object + const querystring = convertQueryString(params.querystring); + + logger.debug( + `${event.statusCode}\n${params.method} ${params.path}${ + querystring ? `\n${querystring}` : '' + }`, + { + tags: ['query'], + } + ); + } + }); +}; + +const convertQueryString = (qs: string | Record | undefined): string => { + if (qs === undefined || typeof qs === 'string') { + return qs ?? ''; + } + return stringify(qs); +}; diff --git a/src/core/server/elasticsearch/client/index.ts b/src/core/server/elasticsearch/client/index.ts new file mode 100644 index 0000000000000..18e84482024ca --- /dev/null +++ b/src/core/server/elasticsearch/client/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ElasticsearchClient } from './types'; +export { IScopedClusterClient, ScopedClusterClient } from './scoped_cluster_client'; +export { ElasticsearchClientConfig } from './client_config'; +export { IClusterClient, ICustomClusterClient, ClusterClient } from './cluster_client'; +export { configureClient } from './configure_client'; diff --git a/src/core/server/elasticsearch/client/mocks.test.ts b/src/core/server/elasticsearch/client/mocks.test.ts new file mode 100644 index 0000000000000..b882f8d0c5d79 --- /dev/null +++ b/src/core/server/elasticsearch/client/mocks.test.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { elasticsearchClientMock } from './mocks'; + +describe('Mocked client', () => { + let client: ReturnType; + + const expectMocked = (fn: jest.MockedFunction | undefined) => { + expect(fn).toBeDefined(); + expect(fn.mockReturnValue).toEqual(expect.any(Function)); + }; + + beforeEach(() => { + client = elasticsearchClientMock.createInternalClient(); + }); + + it('`transport.request` should be mocked', () => { + expectMocked(client.transport.request); + }); + + it('root level API methods should be mocked', () => { + expectMocked(client.bulk); + expectMocked(client.search); + }); + + it('nested level API methods should be mocked', () => { + expectMocked(client.asyncSearch.get); + expectMocked(client.nodes.info); + }); + + it('`close` should be mocked', () => { + expectMocked(client.close); + }); + + it('`child` should be mocked and return a mocked Client', () => { + expectMocked(client.child); + + const child = client.child(); + + expect(child).not.toBe(client); + expectMocked(child.search); + }); +}); diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts new file mode 100644 index 0000000000000..75644435a7f2a --- /dev/null +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Client, ApiResponse } from '@elastic/elasticsearch'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; +import { ElasticsearchClient } from './types'; +import { ICustomClusterClient } from './cluster_client'; + +const createInternalClientMock = (): DeeplyMockedKeys => { + // we mimic 'reflection' on a concrete instance of the client to generate the mocked functions. + const client = new Client({ + node: 'http://localhost', + }) as any; + + const blackListedProps = [ + '_events', + '_eventsCount', + '_maxListeners', + 'name', + 'serializer', + 'connectionPool', + 'transport', + 'helpers', + ]; + + const mockify = (obj: Record, blacklist: string[] = []) => { + Object.keys(obj) + .filter((key) => !blacklist.includes(key)) + .forEach((key) => { + const propType = typeof obj[key]; + if (propType === 'function') { + obj[key] = jest.fn(); + } else if (propType === 'object' && obj[key] != null) { + mockify(obj[key]); + } + }); + }; + + mockify(client, blackListedProps); + + client.transport = { + request: jest.fn(), + }; + client.close = jest.fn().mockReturnValue(Promise.resolve()); + client.child = jest.fn().mockImplementation(() => createInternalClientMock()); + + return (client as unknown) as DeeplyMockedKeys; +}; + +export type ElasticSearchClientMock = DeeplyMockedKeys; + +const createClientMock = (): ElasticSearchClientMock => + (createInternalClientMock() as unknown) as ElasticSearchClientMock; + +interface ScopedClusterClientMock { + asInternalUser: ElasticSearchClientMock; + asCurrentUser: ElasticSearchClientMock; +} + +const createScopedClusterClientMock = () => { + const mock: ScopedClusterClientMock = { + asInternalUser: createClientMock(), + asCurrentUser: createClientMock(), + }; + + return mock; +}; + +export interface ClusterClientMock { + asInternalUser: ElasticSearchClientMock; + asScoped: jest.MockedFunction<() => ScopedClusterClientMock>; +} + +const createClusterClientMock = () => { + const mock: ClusterClientMock = { + asInternalUser: createClientMock(), + asScoped: jest.fn(), + }; + + mock.asScoped.mockReturnValue(createScopedClusterClientMock()); + + return mock; +}; + +export type CustomClusterClientMock = jest.Mocked & ClusterClientMock; + +const createCustomClusterClientMock = () => { + const mock: CustomClusterClientMock = { + asInternalUser: createClientMock(), + asScoped: jest.fn(), + close: jest.fn(), + }; + + mock.asScoped.mockReturnValue(createScopedClusterClientMock()); + mock.close.mockReturnValue(Promise.resolve()); + + return mock; +}; + +export type MockedTransportRequestPromise = TransportRequestPromise & { + abort: jest.MockedFunction<() => undefined>; +}; + +const createMockedClientResponse = (body: T): MockedTransportRequestPromise> => { + const response: ApiResponse = { + body, + statusCode: 200, + warnings: [], + headers: {}, + meta: {} as any, + }; + const promise = Promise.resolve(response); + (promise as MockedTransportRequestPromise>).abort = jest.fn(); + + return promise as MockedTransportRequestPromise>; +}; + +const createMockedClientError = (err: any): MockedTransportRequestPromise => { + const promise = Promise.reject(err); + (promise as MockedTransportRequestPromise).abort = jest.fn(); + return promise as MockedTransportRequestPromise; +}; + +export const elasticsearchClientMock = { + createClusterClient: createClusterClientMock, + createCustomClusterClient: createCustomClusterClientMock, + createScopedClusterClient: createScopedClusterClientMock, + createElasticSearchClient: createClientMock, + createInternalClient: createInternalClientMock, + createClientResponse: createMockedClientResponse, + createClientError: createMockedClientError, +}; diff --git a/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts b/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts new file mode 100644 index 0000000000000..78ca8fcbd3c07 --- /dev/null +++ b/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { elasticsearchClientMock } from './mocks'; +import { ScopedClusterClient } from './scoped_cluster_client'; + +describe('ScopedClusterClient', () => { + it('uses the internal client passed in the constructor', () => { + const internalClient = elasticsearchClientMock.createElasticSearchClient(); + const scopedClient = elasticsearchClientMock.createElasticSearchClient(); + + const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); + + expect(scopedClusterClient.asInternalUser).toBe(internalClient); + }); + + it('uses the scoped client passed in the constructor', () => { + const internalClient = elasticsearchClientMock.createElasticSearchClient(); + const scopedClient = elasticsearchClientMock.createElasticSearchClient(); + + const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); + + expect(scopedClusterClient.asCurrentUser).toBe(scopedClient); + }); +}); diff --git a/src/core/server/elasticsearch/client/scoped_cluster_client.ts b/src/core/server/elasticsearch/client/scoped_cluster_client.ts new file mode 100644 index 0000000000000..1af7948a65e16 --- /dev/null +++ b/src/core/server/elasticsearch/client/scoped_cluster_client.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ElasticsearchClient } from './types'; + +/** + * Serves the same purpose as the normal {@link ClusterClient | cluster client} but exposes + * an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal + * user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers + * extracted from the current user request to the API instead. + * + * @public + **/ +export interface IScopedClusterClient { + /** + * A {@link ElasticsearchClient | client} to be used to query the elasticsearch cluster + * on behalf of the internal Kibana user. + */ + readonly asInternalUser: ElasticsearchClient; + /** + * A {@link ElasticsearchClient | client} to be used to query the elasticsearch cluster + * on behalf of the user that initiated the request to the Kibana server. + */ + readonly asCurrentUser: ElasticsearchClient; +} + +/** @internal **/ +export class ScopedClusterClient implements IScopedClusterClient { + constructor( + public readonly asInternalUser: ElasticsearchClient, + public readonly asCurrentUser: ElasticsearchClient + ) {} +} diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts new file mode 100644 index 0000000000000..934120c330e92 --- /dev/null +++ b/src/core/server/elasticsearch/client/types.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Client } from '@elastic/elasticsearch'; +import type { + ApiResponse, + TransportRequestOptions, + TransportRequestParams, +} from '@elastic/elasticsearch/lib/Transport'; + +/** + * Client used to query the elasticsearch cluster. + * + * @public + */ +export type ElasticsearchClient = Omit< + Client, + 'connectionPool' | 'transport' | 'serializer' | 'extend' | 'helpers' | 'child' | 'close' +> & { + transport: { + request( + params: TransportRequestParams, + options?: TransportRequestOptions + ): Promise; + }; +}; diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index f524781de4c7e..b97f6df6b0afc 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -19,6 +19,11 @@ import { BehaviorSubject } from 'rxjs'; import { ILegacyClusterClient, ILegacyCustomClusterClient } from './legacy'; +import { + elasticsearchClientMock, + ClusterClientMock, + CustomClusterClientMock, +} from './client/mocks'; import { legacyClientMock } from './legacy/mocks'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; @@ -33,6 +38,13 @@ interface MockedElasticSearchServiceSetup { }; } +type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup; + +interface MockedInternalElasticSearchServiceStart extends MockedElasticSearchServiceStart { + client: ClusterClientMock; + createClient: jest.MockedFunction<() => CustomClusterClientMock>; +} + const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = { legacy: { @@ -47,8 +59,6 @@ const createSetupContractMock = () => { return setupContract; }; -type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup; - const createStartContractMock = () => { const startContract: MockedElasticSearchServiceStart = { legacy: { @@ -60,6 +70,17 @@ const createStartContractMock = () => { startContract.legacy.client.asScoped.mockReturnValue( legacyClientMock.createScopedClusterClient() ); + return startContract; +}; + +const createInternalStartContractMock = () => { + const startContract: MockedInternalElasticSearchServiceStart = { + ...createStartContractMock(), + client: elasticsearchClientMock.createClusterClient(), + createClient: jest.fn(), + }; + + startContract.createClient.mockReturnValue(elasticsearchClientMock.createCustomClusterClient()); return startContract; }; @@ -100,7 +121,7 @@ const createMock = () => { stop: jest.fn(), }; mocked.setup.mockResolvedValue(createInternalSetupContractMock()); - mocked.start.mockResolvedValueOnce(createStartContractMock()); + mocked.start.mockResolvedValueOnce(createInternalStartContractMock()); mocked.stop.mockResolvedValue(); return mocked; }; @@ -109,6 +130,7 @@ export const elasticsearchServiceMock = { create: createMock, createInternalSetup: createInternalSetupContractMock, createSetup: createSetupContractMock, + createInternalStart: createInternalStartContractMock, createStart: createStartContractMock, createLegacyClusterClient: legacyClientMock.createClusterClient, createLegacyCustomClusterClient: legacyClientMock.createCustomClusterClient, diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.mocks.ts b/src/core/server/elasticsearch/elasticsearch_service.test.mocks.ts index c30230a7847a0..955ab197ffce1 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.mocks.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.mocks.ts @@ -17,5 +17,8 @@ * under the License. */ +export const MockLegacyClusterClient = jest.fn(); +jest.mock('./legacy/cluster_client', () => ({ LegacyClusterClient: MockLegacyClusterClient })); + export const MockClusterClient = jest.fn(); -jest.mock('./legacy/cluster_client', () => ({ LegacyClusterClient: MockClusterClient })); +jest.mock('./client/cluster_client', () => ({ ClusterClient: MockClusterClient })); diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 8f3dc5688f6fc..b36af2a7e4671 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -19,7 +19,7 @@ import { first } from 'rxjs/operators'; -import { MockClusterClient } from './elasticsearch_service.test.mocks'; +import { MockLegacyClusterClient, MockClusterClient } from './elasticsearch_service.test.mocks'; import { BehaviorSubject } from 'rxjs'; import { Env } from '../config'; @@ -28,9 +28,11 @@ import { CoreContext } from '../core_context'; import { configServiceMock } from '../config/config_service.mock'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServiceMock } from '../http/http_service.mock'; +import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; import { elasticsearchServiceMock } from './elasticsearch_service.mock'; +import { elasticsearchClientMock } from './client/mocks'; import { duration } from 'moment'; const delay = async (durationMs: number) => @@ -38,9 +40,12 @@ const delay = async (durationMs: number) => let elasticsearchService: ElasticsearchService; const configService = configServiceMock.create(); -const deps = { +const setupDeps = { http: httpServiceMock.createInternalSetupContract(), }; +const startDeps = { + auditTrail: auditTrailServiceMock.createStartContract(), +}; configService.atPath.mockReturnValue( new BehaviorSubject({ hosts: ['http://1.2.3.4'], @@ -56,49 +61,58 @@ configService.atPath.mockReturnValue( let env: Env; let coreContext: CoreContext; const logger = loggingSystemMock.create(); + +let mockClusterClientInstance: ReturnType; +let mockLegacyClusterClientInstance: ReturnType; + beforeEach(() => { env = Env.createDefault(getEnvOptions()); coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; elasticsearchService = new ElasticsearchService(coreContext); + + MockLegacyClusterClient.mockClear(); + MockClusterClient.mockClear(); + + mockLegacyClusterClientInstance = elasticsearchServiceMock.createLegacyCustomClusterClient(); + MockLegacyClusterClient.mockImplementation(() => mockLegacyClusterClientInstance); + mockClusterClientInstance = elasticsearchClientMock.createCustomClusterClient(); + MockClusterClient.mockImplementation(() => mockClusterClientInstance); }); afterEach(() => jest.clearAllMocks()); describe('#setup', () => { it('returns legacy Elasticsearch config as a part of the contract', async () => { - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); await expect(setupContract.legacy.config$.pipe(first()).toPromise()).resolves.toBeInstanceOf( ElasticsearchConfig ); }); - it('returns elasticsearch client as a part of the contract', async () => { - const mockClusterClientInstance = elasticsearchServiceMock.createLegacyClusterClient(); - MockClusterClient.mockImplementationOnce(() => mockClusterClientInstance); - - const setupContract = await elasticsearchService.setup(deps); + it('returns legacy elasticsearch client as a part of the contract', async () => { + const setupContract = await elasticsearchService.setup(setupDeps); const client = setupContract.legacy.client; - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); await client.callAsInternalUser('any'); - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); }); - describe('#createClient', () => { + describe('#createLegacyClient', () => { it('allows to specify config properties', async () => { - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); - const mockClusterClientInstance = { close: jest.fn() }; - MockClusterClient.mockImplementation(() => mockClusterClientInstance); + // reset all mocks called during setup phase + MockLegacyClusterClient.mockClear(); const customConfig = { logQueries: true }; const clusterClient = setupContract.legacy.createClient('some-custom-type', customConfig); - expect(clusterClient).toBe(mockClusterClientInstance); + expect(clusterClient).toBe(mockLegacyClusterClientInstance); - expect(MockClusterClient).toHaveBeenCalledWith( + expect(MockLegacyClusterClient).toHaveBeenCalledWith( expect.objectContaining(customConfig), expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }), expect.any(Function), @@ -107,9 +121,10 @@ describe('#setup', () => { }); it('falls back to elasticsearch default config values if property not specified', async () => { - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); + // reset all mocks called during setup phase - MockClusterClient.mockClear(); + MockLegacyClusterClient.mockClear(); const customConfig = { hosts: ['http://8.8.8.8'], @@ -118,7 +133,7 @@ describe('#setup', () => { }; setupContract.legacy.createClient('some-custom-type', customConfig); - const config = MockClusterClient.mock.calls[0][0]; + const config = MockLegacyClusterClient.mock.calls[0][0]; expect(config).toMatchInlineSnapshot(` Object { "healthCheckDelay": "PT0.01S", @@ -137,13 +152,14 @@ describe('#setup', () => { `); }); it('falls back to elasticsearch config if custom config not passed', async () => { - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); + // reset all mocks called during setup phase - MockClusterClient.mockClear(); + MockLegacyClusterClient.mockClear(); setupContract.legacy.createClient('another-type'); - const config = MockClusterClient.mock.calls[0][0]; + const config = MockLegacyClusterClient.mock.calls[0][0]; expect(config).toMatchInlineSnapshot(` Object { "healthCheckDelay": "PT0.01S", @@ -178,9 +194,10 @@ describe('#setup', () => { } as any) ); elasticsearchService = new ElasticsearchService(coreContext); - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); + // reset all mocks called during setup phase - MockClusterClient.mockClear(); + MockLegacyClusterClient.mockClear(); const customConfig = { hosts: ['http://8.8.8.8'], @@ -189,7 +206,7 @@ describe('#setup', () => { }; setupContract.legacy.createClient('some-custom-type', customConfig); - const config = MockClusterClient.mock.calls[0][0]; + const config = MockLegacyClusterClient.mock.calls[0][0]; expect(config).toMatchInlineSnapshot(` Object { "healthCheckDelay": "PT2S", @@ -210,66 +227,142 @@ describe('#setup', () => { }); it('esNodeVersionCompatibility$ only starts polling when subscribed to', async (done) => { - const clusterClientInstance = elasticsearchServiceMock.createLegacyClusterClient(); - MockClusterClient.mockImplementationOnce(() => clusterClientInstance); + mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); - clusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); - - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); await delay(10); - expect(clusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); setupContract.esNodesCompatibility$.subscribe(() => { - expect(clusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); done(); }); }); it('esNodeVersionCompatibility$ stops polling when unsubscribed from', async (done) => { - const mockClusterClientInstance = elasticsearchServiceMock.createLegacyClusterClient(); - MockClusterClient.mockImplementationOnce(() => mockClusterClientInstance); - - mockClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); + mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); const sub = setupContract.esNodesCompatibility$.subscribe(async () => { sub.unsubscribe(); await delay(100); - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); done(); }); }); }); -describe('#stop', () => { - it('stops both admin and data clients', async () => { - const mockClusterClientInstance = { close: jest.fn() }; - MockClusterClient.mockImplementationOnce(() => mockClusterClientInstance); +describe('#start', () => { + it('throws if called before `setup`', async () => { + expect(() => elasticsearchService.start(startDeps)).rejects.toMatchInlineSnapshot( + `[Error: ElasticsearchService needs to be setup before calling start]` + ); + }); + + it('returns elasticsearch client as a part of the contract', async () => { + await elasticsearchService.setup(setupDeps); + const startContract = await elasticsearchService.start(startDeps); + const client = startContract.client; + + expect(client.asInternalUser).toBe(mockClusterClientInstance.asInternalUser); + }); + + describe('#createClient', () => { + it('allows to specify config properties', async () => { + await elasticsearchService.setup(setupDeps); + const startContract = await elasticsearchService.start(startDeps); + + // reset all mocks called during setup phase + MockClusterClient.mockClear(); + + const customConfig = { logQueries: true }; + const clusterClient = startContract.createClient('custom-type', customConfig); + + expect(clusterClient).toBe(mockClusterClientInstance); + + expect(MockClusterClient).toHaveBeenCalledTimes(1); + expect(MockClusterClient).toHaveBeenCalledWith( + expect.objectContaining(customConfig), + expect.objectContaining({ context: ['elasticsearch', 'custom-type'] }), + expect.any(Function) + ); + }); + it('creates a new client on each call', async () => { + await elasticsearchService.setup(setupDeps); + const startContract = await elasticsearchService.start(startDeps); + + // reset all mocks called during setup phase + MockClusterClient.mockClear(); + + const customConfig = { logQueries: true }; + + startContract.createClient('custom-type', customConfig); + startContract.createClient('another-type', customConfig); + + expect(MockClusterClient).toHaveBeenCalledTimes(2); + }); + + it('falls back to elasticsearch default config values if property not specified', async () => { + await elasticsearchService.setup(setupDeps); + const startContract = await elasticsearchService.start(startDeps); + + // reset all mocks called during setup phase + MockClusterClient.mockClear(); + + const customConfig = { + hosts: ['http://8.8.8.8'], + logQueries: true, + ssl: { certificate: 'certificate-value' }, + }; + + startContract.createClient('some-custom-type', customConfig); + const config = MockClusterClient.mock.calls[0][0]; - await elasticsearchService.setup(deps); + expect(config).toMatchInlineSnapshot(` + Object { + "healthCheckDelay": "PT0.01S", + "hosts": Array [ + "http://8.8.8.8", + ], + "logQueries": true, + "requestHeadersWhitelist": Array [ + undefined, + ], + "ssl": Object { + "certificate": "certificate-value", + "verificationMode": "none", + }, + } + `); + }); + }); +}); + +describe('#stop', () => { + it('stops both legacy and new clients', async () => { + await elasticsearchService.setup(setupDeps); + await elasticsearchService.start(startDeps); await elasticsearchService.stop(); + expect(mockLegacyClusterClientInstance.close).toHaveBeenCalledTimes(1); expect(mockClusterClientInstance.close).toHaveBeenCalledTimes(1); }); it('stops pollEsNodeVersions even if there are active subscriptions', async (done) => { expect.assertions(2); - const mockClusterClientInstance = elasticsearchServiceMock.createLegacyCustomClusterClient(); - - MockClusterClient.mockImplementationOnce(() => mockClusterClientInstance); - mockClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); + mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); - const setupContract = await elasticsearchService.setup(deps); + const setupContract = await elasticsearchService.setup(setupDeps); setupContract.esNodesCompatibility$.subscribe(async () => { - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); await elasticsearchService.stop(); await delay(100); - expect(mockClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); done(); }); }); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 4ea10f6ae4e2e..9b05fb9887a3b 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -17,17 +17,8 @@ * under the License. */ -import { ConnectableObservable, Observable, Subscription, Subject } from 'rxjs'; -import { - filter, - first, - map, - publishReplay, - switchMap, - take, - shareReplay, - takeUntil, -} from 'rxjs/operators'; +import { Observable, Subject } from 'rxjs'; +import { first, map, shareReplay, takeUntil } from 'rxjs/operators'; import { CoreService } from '../../types'; import { merge } from '../../utils'; @@ -35,28 +26,17 @@ import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { LegacyClusterClient, - ILegacyClusterClient, ILegacyCustomClusterClient, LegacyElasticsearchClientConfig, - LegacyCallAPIOptions, } from './legacy'; +import { ClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; import { AuditTrailStart, AuditorFactory } from '../audit_trail'; -import { - InternalElasticsearchServiceSetup, - ElasticsearchServiceStart, - ScopeableRequest, -} from './types'; +import { InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart } from './types'; import { pollEsNodesVersion } from './version_check/ensure_es_version'; import { calculateStatus$ } from './status'; -/** @internal */ -interface CoreClusterClients { - config: ElasticsearchConfig; - client: LegacyClusterClient; -} - interface SetupDeps { http: InternalHttpServiceSetup; } @@ -67,18 +47,21 @@ interface StartDeps { /** @internal */ export class ElasticsearchService - implements CoreService { + implements CoreService { private readonly log: Logger; private readonly config$: Observable; - private subscription?: Subscription; private auditorFactory?: AuditorFactory; private stop$ = new Subject(); private kibanaVersion: string; - private createClient?: ( + private getAuthHeaders?: GetAuthHeaders; + + private createLegacyCustomClient?: ( type: string, clientConfig?: Partial ) => ILegacyCustomClusterClient; - private client?: ILegacyClusterClient; + private legacyClient?: LegacyClusterClient; + + private client?: ClusterClient; constructor(private readonly coreContext: CoreContext) { this.kibanaVersion = coreContext.env.packageInfo.version; @@ -91,139 +74,86 @@ export class ElasticsearchService public async setup(deps: SetupDeps): Promise { this.log.debug('Setting up elasticsearch service'); - const clients$ = this.config$.pipe( - filter(() => { - if (this.subscription !== undefined) { - this.log.error('Clients cannot be changed after they are created'); - return false; - } - - return true; - }), - switchMap( - (config) => - new Observable((subscriber) => { - this.log.debug('Creating elasticsearch client'); - - const coreClients = { - config, - client: this.createClusterClient('data', config, deps.http.getAuthHeaders), - }; - - subscriber.next(coreClients); - - return () => { - this.log.debug('Closing elasticsearch client'); - - coreClients.client.close(); - }; - }) - ), - publishReplay(1) - ) as ConnectableObservable; - - this.subscription = clients$.connect(); - const config = await this.config$.pipe(first()).toPromise(); - const client$ = clients$.pipe(map((clients) => clients.client)); - - const client = { - async callAsInternalUser( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) { - const _client = await client$.pipe(take(1)).toPromise(); - return await _client.callAsInternalUser(endpoint, clientParams, options); - }, - asScoped(request: ScopeableRequest) { - const _clientPromise = client$.pipe(take(1)).toPromise(); - return { - async callAsInternalUser( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) { - const _client = await _clientPromise; - return await _client - .asScoped(request) - .callAsInternalUser(endpoint, clientParams, options); - }, - async callAsCurrentUser( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) { - const _client = await _clientPromise; - return await _client - .asScoped(request) - .callAsCurrentUser(endpoint, clientParams, options); - }, - }; - }, - }; - - this.client = client; + this.getAuthHeaders = deps.http.getAuthHeaders; + this.legacyClient = this.createLegacyClusterClient('data', config); const esNodesCompatibility$ = pollEsNodesVersion({ - callWithInternalUser: client.callAsInternalUser, + callWithInternalUser: this.legacyClient.callAsInternalUser, log: this.log, ignoreVersionMismatch: config.ignoreVersionMismatch, esVersionCheckInterval: config.healthCheckDelay.asMilliseconds(), kibanaVersion: this.kibanaVersion, }).pipe(takeUntil(this.stop$), shareReplay({ refCount: true, bufferSize: 1 })); - this.createClient = ( - type: string, - clientConfig: Partial = {} - ) => { + this.createLegacyCustomClient = (type, clientConfig = {}) => { const finalConfig = merge({}, config, clientConfig); - return this.createClusterClient(type, finalConfig, deps.http.getAuthHeaders); + return this.createLegacyClusterClient(type, finalConfig); }; return { legacy: { - config$: clients$.pipe(map((clients) => clients.config)), - client, - createClient: this.createClient, + config$: this.config$, + client: this.legacyClient, + createClient: this.createLegacyCustomClient, }, esNodesCompatibility$, status$: calculateStatus$(esNodesCompatibility$), }; } - public async start({ auditTrail }: StartDeps) { + public async start({ auditTrail }: StartDeps): Promise { this.auditorFactory = auditTrail; - if (typeof this.client === 'undefined' || typeof this.createClient === 'undefined') { + if (!this.legacyClient || !this.createLegacyCustomClient) { throw new Error('ElasticsearchService needs to be setup before calling start'); - } else { - return { - legacy: { - client: this.client, - createClient: this.createClient, - }, - }; } + + const config = await this.config$.pipe(first()).toPromise(); + this.client = this.createClusterClient('data', config); + + const createClient = ( + type: string, + clientConfig: Partial = {} + ): ICustomClusterClient => { + const finalConfig = merge({}, config, clientConfig); + return this.createClusterClient(type, finalConfig); + }; + + return { + client: this.client, + createClient, + legacy: { + client: this.legacyClient, + createClient: this.createLegacyCustomClient, + }, + }; } public async stop() { this.log.debug('Stopping elasticsearch service'); - if (this.subscription !== undefined) { - this.subscription.unsubscribe(); - } this.stop$.next(); + if (this.client) { + this.client.close(); + } + if (this.legacyClient) { + this.legacyClient.close(); + } } - private createClusterClient( - type: string, - config: LegacyElasticsearchClientConfig, - getAuthHeaders?: GetAuthHeaders - ) { + private createClusterClient(type: string, config: ElasticsearchClientConfig) { + return new ClusterClient( + config, + this.coreContext.logger.get('elasticsearch', type), + this.getAuthHeaders + ); + } + + private createLegacyClusterClient(type: string, config: LegacyElasticsearchClientConfig) { return new LegacyClusterClient( config, this.coreContext.logger.get('elasticsearch', type), this.getAuditorFactory, - getAuthHeaders + this.getAuthHeaders ); } diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index f5f5f5cc7b6f8..8bb77b5dfdee0 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -25,7 +25,15 @@ export { ElasticsearchServiceStart, ElasticsearchStatusMeta, InternalElasticsearchServiceSetup, + InternalElasticsearchServiceStart, FakeRequest, ScopeableRequest, } from './types'; export * from './legacy'; +export { + IClusterClient, + ICustomClusterClient, + ElasticsearchClientConfig, + ElasticsearchClient, + IScopedClusterClient, +} from './client'; diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 2b4ba4b0a0a55..40399aecbc446 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -26,6 +26,7 @@ import { ILegacyClusterClient, ILegacyCustomClusterClient, } from './legacy'; +import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus } from '../status'; @@ -80,6 +81,16 @@ export interface ElasticsearchServiceSetup { }; } +/** @internal */ +export interface InternalElasticsearchServiceSetup { + // Required for the BWC with the legacy Kibana only. + readonly legacy: ElasticsearchServiceSetup['legacy'] & { + readonly config$: Observable; + }; + esNodesCompatibility$: Observable; + status$: Observable>; +} + /** * @public */ @@ -103,7 +114,7 @@ export interface ElasticsearchServiceStart { * * @example * ```js - * const client = elasticsearch.createCluster('my-app-name', config); + * const client = elasticsearch.legacy.createClient('my-app-name', config); * const data = await client.callAsInternalUser(); * ``` */ @@ -113,26 +124,51 @@ export interface ElasticsearchServiceStart { ) => ILegacyCustomClusterClient; /** - * A pre-configured Elasticsearch client. All Elasticsearch config value changes are processed under the hood. - * See {@link ILegacyClusterClient}. + * A pre-configured {@link ILegacyClusterClient | legacy Elasticsearch client}. * * @example * ```js - * const client = core.elasticsearch.client; + * const client = core.elasticsearch.legacy.client; * ``` */ readonly client: ILegacyClusterClient; }; } -/** @internal */ -export interface InternalElasticsearchServiceSetup { - // Required for the BWC with the legacy Kibana only. - readonly legacy: ElasticsearchServiceSetup['legacy'] & { - readonly config$: Observable; - }; - esNodesCompatibility$: Observable; - status$: Observable>; +/** + * @internal + */ +export interface InternalElasticsearchServiceStart extends ElasticsearchServiceStart { + /** + * A pre-configured {@link IClusterClient | Elasticsearch client} + * + * @example + * ```js + * const client = core.elasticsearch.client; + * ``` + */ + readonly client: IClusterClient; + /** + * Create application specific Elasticsearch cluster API client with customized config. See {@link IClusterClient}. + * + * @param type Unique identifier of the client + * @param clientConfig A config consists of Elasticsearch JS client options and + * valid sub-set of Elasticsearch service config. + * We fill all the missing properties in the `clientConfig` using the default + * Elasticsearch config so that we don't depend on default values set and + * controlled by underlying Elasticsearch JS client. + * We don't run validation against the passed config and expect it to be valid. + * + * @example + * ```js + * const client = elasticsearch.createClient('my-app-name', config); + * const data = await client.asInternalUser().search(); + * ``` + */ + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; } /** @public */ diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.ts index 3f562dac22a02..dc56d982d7b4a 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.ts @@ -29,7 +29,7 @@ import { esVersionEqualsKibana, } from './es_kibana_version_compatability'; import { Logger } from '../../logging'; -import { LegacyAPICaller } from '..'; +import { LegacyAPICaller } from '../legacy'; export interface PollEsNodesVersionOptions { callWithInternalUser: LegacyAPICaller; diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 24080f2529beb..4f4bf50f07b8e 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -22,7 +22,10 @@ import { Type } from '@kbn/config-schema'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { ConfigDeprecationProvider } from './config'; import { ContextSetup } from './context'; -import { InternalElasticsearchServiceSetup, ElasticsearchServiceStart } from './elasticsearch'; +import { + InternalElasticsearchServiceSetup, + InternalElasticsearchServiceStart, +} from './elasticsearch'; import { InternalHttpServiceSetup, InternalHttpServiceStart } from './http'; import { InternalSavedObjectsServiceSetup, @@ -58,7 +61,7 @@ export interface InternalCoreSetup { */ export interface InternalCoreStart { capabilities: CapabilitiesStart; - elasticsearch: ElasticsearchServiceStart; + elasticsearch: InternalElasticsearchServiceStart; http: InternalHttpServiceStart; metrics: InternalMetricsServiceStart; savedObjects: InternalSavedObjectsServiceStart; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 75ca88627814b..a3dbb279d19eb 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -177,7 +177,7 @@ function createInternalCoreSetupMock() { function createInternalCoreStartMock() { const startDeps: InternalCoreStart = { capabilities: capabilitiesServiceMock.createStartContract(), - elasticsearch: elasticsearchServiceMock.createStart(), + elasticsearch: elasticsearchServiceMock.createInternalStart(), http: httpServiceMock.createInternalStartContract(), metrics: metricsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index b0f9ff6fd5ebd..a6dd13a12b527 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -210,7 +210,9 @@ export function createPluginStartContext( capabilities: { resolveCapabilities: deps.capabilities.resolveCapabilities, }, - elasticsearch: deps.elasticsearch, + elasticsearch: { + legacy: deps.elasticsearch.legacy, + }, http: { auth: deps.http.auth, basePath: deps.http.basePath, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ea95329bf8fa4..107edf11bb6f4 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -4,6 +4,7 @@ ```ts +import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; import Boom from 'boom'; import { BulkIndexDocumentsParams } from 'elasticsearch'; import { CatAliasesParams } from 'elasticsearch'; @@ -21,6 +22,8 @@ import { CatTasksParams } from 'elasticsearch'; import { CatThreadPoolParams } from 'elasticsearch'; import { ClearScrollParams } from 'elasticsearch'; import { Client } from 'elasticsearch'; +import { Client as Client_2 } from '@elastic/elasticsearch'; +import { ClientOptions } from '@elastic/elasticsearch'; import { ClusterAllocationExplainParams } from 'elasticsearch'; import { ClusterGetSettingsParams } from 'elasticsearch'; import { ClusterHealthParams } from 'elasticsearch'; @@ -138,6 +141,8 @@ import { TasksCancelParams } from 'elasticsearch'; import { TasksGetParams } from 'elasticsearch'; import { TasksListParams } from 'elasticsearch'; import { TermvectorsParams } from 'elasticsearch'; +import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; +import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UpdateDocumentByQueryParams } from 'elasticsearch'; diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/api_integration/apis/fleet/agents/acks.ts index 45833012cb475..e8381aa9d59ea 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/acks.ts @@ -22,7 +22,7 @@ export default function (providerContext: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); - const { body: apiKeyBody } = await esClient.security.createApiKey({ + const { body: apiKeyBody } = await esClient.security.createApiKey({ body: { name: `test access api key: ${uuid.v4()}`, }, diff --git a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts index d24f7f495a06c..8942deafdd83c 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts @@ -22,7 +22,7 @@ export default function (providerContext: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); - const { body: apiKeyBody } = await esClient.security.createApiKey({ + const { body: apiKeyBody } = await esClient.security.createApiKey({ body: { name: `test access api key: ${uuid.v4()}`, }, diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts index b4d23a2392320..e9f7471f6437e 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts @@ -25,7 +25,7 @@ export default function (providerContext: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); - const { body: apiKeyBody } = await esClient.security.createApiKey({ + const { body: apiKeyBody } = await esClient.security.createApiKey({ body: { name: `test access api key: ${uuid.v4()}`, }, diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts index 76cea64bffc1c..d13b9836f25a1 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { ClusterStateAPIResponse } from '../../../plugins/upgrade_assistant/common/types'; import { getIndexStateFromClusterState } from '../../../plugins/upgrade_assistant/common/get_index_state_from_cluster_state'; // eslint-disable-next-line import/no-default-export @@ -28,7 +29,7 @@ export default function ({ getService }: FtrProviderContext) { it('the _cluster/state endpoint is still what we expect', async () => { await esArchiver.load('upgrade_assistant/reindex'); await es.indices.close({ index: '7.0-data' }); - const result = await es.cluster.state({ + const result = await es.cluster.state({ index: '7.0-data', metric: 'metadata', }); diff --git a/yarn.lock b/yarn.lock index acf7c3a1e8754..2d575634686a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2172,6 +2172,17 @@ redux-immutable-state-invariant "^2.1.0" redux-logger "^3.0.6" +"@elastic/elasticsearch@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.8.0.tgz#3f9ee54fe8ef79874ebd231db03825fa500a7111" + integrity sha512-rUOTNN1At0KoN0Fcjd6+J7efghuURnoMTB/od9EMK6Mcdebi6N3z5ulShTsKRn6OanS9Eq3l/OmheQY1Y+WLcg== + dependencies: + debug "^4.1.1" + decompress-response "^4.2.0" + ms "^2.1.1" + pump "^3.0.0" + secure-json-parse "^2.1.0" + "@elastic/elasticsearch@^7.4.0": version "7.4.0" resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.4.0.tgz#57f4066acf25e9d4e9b4f6376088433aae6f25d4" @@ -27784,6 +27795,11 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" +secure-json-parse@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.1.0.tgz#ae76f5624256b5c497af887090a5d9e156c9fb20" + integrity sha512-GckO+MS/wT4UogDyoI/H/S1L0MCcKS1XX/vp48wfmU7Nw4woBmb8mIpu4zPBQjKlRT88/bt9xdoV4111jPpNJA== + seedrandom@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" From 5326d2c614f3aefbf4dde7af518182c3c0d6acf1 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 8 Jul 2020 12:14:42 -0400 Subject: [PATCH 04/10] [ML] DF Analytics functional tests: re-enable regression, classification, and outlier creation (#71006) * update mml test. re-enable reg, class, and outlier creation tests * remove unnecessary second argument --- .../ml/data_frame_analytics/classification_creation.ts | 8 ++++---- .../data_frame_analytics/outlier_detection_creation.ts | 7 +++---- .../apps/ml/data_frame_analytics/regression_creation.ts | 8 ++++---- .../services/ml/data_frame_analytics_creation.ts | 9 +++++++++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 4a79610cadbde..2c6edeba2129f 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -10,8 +10,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // flaky test https://github.com/elastic/kibana/issues/70455 - describe.skip('classification creation', function () { + + describe('classification creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/bm_classification'); await ml.testResources.createIndexPatternIfNeeded('ft_bank_marketing', '@timestamp'); @@ -96,9 +96,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); }); - it('inputs the model memory limit', async () => { + it('accepts the suggested model memory limit', async () => { await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); - await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); }); it('continues to the details step', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 65e6dc9b4ea74..6cdb9caa1e2db 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -11,8 +11,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // Flaky: https://github.com/elastic/kibana/issues/70906 - describe.skip('outlier detection creation', function () { + describe('outlier detection creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/ihp_outlier'); await ml.testResources.createIndexPatternIfNeeded('ft_ihp_outlier', '@timestamp'); @@ -93,9 +92,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); }); - it('inputs the model memory limit', async () => { + it('accepts the suggested model memory limit', async () => { await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); - await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); }); it('continues to the details step', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 33f0ee9cd99ac..03117d4cc419d 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -10,8 +10,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // flaky test https://github.com/elastic/kibana/issues/70455 - describe.skip('regression creation', function () { + + describe('regression creation', function () { before(async () => { await esArchiver.loadIfNeeded('ml/egs_regression'); await ml.testResources.createIndexPatternIfNeeded('ft_egs_regression', '@timestamp'); @@ -96,9 +96,9 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); }); - it('inputs the model memory limit', async () => { + it('accepts the suggested model memory limit', async () => { await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); - await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); }); it('continues to the details step', async () => { diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 918c982de02ed..1b756bbaca5d8 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -306,6 +306,15 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( ); }, + async assertModelMemoryInputPopulated() { + const actualModelMemory = await testSubjects.getAttribute( + 'mlAnalyticsCreateJobWizardModelMemoryInput', + 'value' + ); + + expect(actualModelMemory).not.to.be(''); + }, + async assertPredictionFieldNameValue(expectedValue: string) { const actualPredictedFieldName = await testSubjects.getAttribute( 'mlAnalyticsCreateJobWizardPredictionFieldNameInput', From 93ac059cacc9ebf472a0d67775ed877746266cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 8 Jul 2020 17:30:58 +0100 Subject: [PATCH 05/10] [Usage Collector] Fix schema types to allow arrays (#70988) * [Usage Collector] Fix schema types to allow arrays * More and better tests Co-authored-by: Elastic Machine --- .../src/tools/__fixture__/mock_schema.json | 13 +- .../__fixture__/parsed_working_collector.ts | 21 ++ .../extract_collectors.test.ts.snap | 25 ++ .../telemetry_collectors/working_collector.ts | 16 ++ .../server/collector/collector.test.ts | 213 ++++++++++++++++++ .../server/collector/collector.ts | 14 +- 6 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 src/plugins/usage_collection/server/collector/collector.test.ts diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json index 885fe0e38dacf..e87699825b4e1 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json @@ -17,7 +17,18 @@ "type": "boolean" } } - } + }, + "my_array": { + "properties": { + "total": { + "type": "number" + }, + "type": { + "type": "boolean" + } + } + }, + "my_str_array": { "type": "keyword" } } } } diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts index 25e49ea221c94..803bc7f13f59e 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts @@ -40,6 +40,13 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ type: 'boolean', }, }, + my_array: { + total: { + type: 'number', + }, + type: { type: 'boolean' }, + }, + my_str_array: { type: 'keyword' }, }, }, fetch: { @@ -63,6 +70,20 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ type: 'BooleanKeyword', }, }, + my_array: { + total: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + type: { + kind: SyntaxKind.BooleanKeyword, + type: 'BooleanKeyword', + }, + }, + my_str_array: { + kind: SyntaxKind.StringKeyword, + type: 'StringKeyword', + }, }, }, }, diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap index 44a12dfa9030c..fc933b6c7fd35 100644 --- a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap +++ b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap @@ -122,6 +122,16 @@ Array [ "kind": 143, "type": "StringKeyword", }, + "my_array": Object { + "total": Object { + "kind": 140, + "type": "NumberKeyword", + }, + "type": Object { + "kind": 128, + "type": "BooleanKeyword", + }, + }, "my_objects": Object { "total": Object { "kind": 140, @@ -136,6 +146,10 @@ Array [ "kind": 143, "type": "StringKeyword", }, + "my_str_array": Object { + "kind": 143, + "type": "StringKeyword", + }, }, "typeName": "Usage", }, @@ -144,6 +158,14 @@ Array [ "flat": Object { "type": "keyword", }, + "my_array": Object { + "total": Object { + "type": "number", + }, + "type": Object { + "type": "boolean", + }, + }, "my_objects": Object { "total": Object { "type": "number", @@ -155,6 +177,9 @@ Array [ "my_str": Object { "type": "text", }, + "my_str_array": Object { + "type": "keyword", + }, }, }, }, diff --git a/src/fixtures/telemetry_collectors/working_collector.ts b/src/fixtures/telemetry_collectors/working_collector.ts index d70a247c61e70..d58a89db97d74 100644 --- a/src/fixtures/telemetry_collectors/working_collector.ts +++ b/src/fixtures/telemetry_collectors/working_collector.ts @@ -33,6 +33,8 @@ interface Usage { flat?: string; my_str?: string; my_objects: MyObject; + my_array?: MyObject[]; + my_str_array?: string[]; } const SOME_NUMBER: number = 123; @@ -54,6 +56,13 @@ export const myCollector = makeUsageCollector({ total: SOME_NUMBER, type: true, }, + my_array: [ + { + total: SOME_NUMBER, + type: true, + }, + ], + my_str_array: ['hello', 'world'], }; } catch (err) { return { @@ -77,5 +86,12 @@ export const myCollector = makeUsageCollector({ }, type: { type: 'boolean' }, }, + my_array: { + total: { + type: 'number', + }, + type: { type: 'boolean' }, + }, + my_str_array: { type: 'keyword' }, }, }); diff --git a/src/plugins/usage_collection/server/collector/collector.test.ts b/src/plugins/usage_collection/server/collector/collector.test.ts new file mode 100644 index 0000000000000..a3e2425c1f122 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector.test.ts @@ -0,0 +1,213 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { loggingSystemMock } from '../../../../core/server/mocks'; +import { Collector } from './collector'; +import { UsageCollector } from './usage_collector'; + +const logger = loggingSystemMock.createLogger(); + +describe('collector', () => { + describe('options validations', () => { + it('should not accept an empty object', () => { + // @ts-expect-error + expect(() => new Collector(logger, {})).toThrowError( + 'Collector must be instantiated with a options.type string property' + ); + }); + + it('should fail if init is not a function', () => { + expect( + () => + new Collector(logger, { + type: 'my_test_collector', + // @ts-expect-error + init: 1, + }) + ).toThrowError( + 'If init property is passed, Collector must be instantiated with a options.init as a function property' + ); + }); + + it('should fail if fetch is not defined', () => { + expect( + () => + // @ts-expect-error + new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + }) + ).toThrowError('Collector must be instantiated with a options.fetch function property'); + }); + + it('should fail if fetch is not a function', () => { + expect( + () => + new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + // @ts-expect-error + fetch: 1, + }) + ).toThrowError('Collector must be instantiated with a options.fetch function property'); + }); + + it('should be OK with all mandatory properties', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => ({ testPass: 100 }), + }); + expect(collector).toBeDefined(); + }); + + it('should fallback when isReady is not provided', () => { + const fetchOutput = { testPass: 100 }; + // @ts-expect-error not providing isReady to test the logic fallback + const collector = new Collector(logger, { + type: 'my_test_collector', + fetch: () => fetchOutput, + }); + expect(collector.isReady()).toBe(true); + }); + }); + + describe('formatForBulkUpload', () => { + it('should use the default formatter', () => { + const fetchOutput = { testPass: 100 }; + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => fetchOutput, + }); + expect(collector.formatForBulkUpload(fetchOutput)).toStrictEqual({ + type: 'my_test_collector', + payload: fetchOutput, + }); + }); + + it('should use a custom formatter', () => { + const fetchOutput = { testPass: 100 }; + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => fetchOutput, + formatForBulkUpload: (a) => ({ type: 'other_value', payload: { nested: a } }), + }); + expect(collector.formatForBulkUpload(fetchOutput)).toStrictEqual({ + type: 'other_value', + payload: { nested: fetchOutput }, + }); + }); + + it("should use UsageCollector's default formatter", () => { + const fetchOutput = { testPass: 100 }; + const collector = new UsageCollector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => fetchOutput, + }); + expect(collector.formatForBulkUpload(fetchOutput)).toStrictEqual({ + type: 'kibana_stats', + payload: { usage: { my_test_collector: fetchOutput } }, + }); + }); + }); + + describe('schema TS validations', () => { + // These tests below are used to ensure types inference is working as expected. + // We don't intend to test any logic as such, just the relation between the types in `fetch` and `schema`. + // Using ts-expect-error when an error is expected will fail the compilation if there is not such error. + + test('when fetch returns a simple object', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => ({ testPass: 100 }), + schema: { + testPass: { type: 'long' }, + }, + }); + expect(collector).toBeDefined(); + }); + + test('when fetch returns array-properties and schema', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => ({ testPass: [{ name: 'a', value: 100 }] }), + schema: { + testPass: { name: { type: 'keyword' }, value: { type: 'long' } }, + }, + }); + expect(collector).toBeDefined(); + }); + + test('TS should complain when schema is missing some properties', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + fetch: () => ({ testPass: [{ name: 'a', value: 100 }], otherProp: 1 }), + // @ts-expect-error + schema: { + testPass: { name: { type: 'keyword' }, value: { type: 'long' } }, + }, + }); + expect(collector).toBeDefined(); + }); + + test('TS complains if schema misses any of the optional properties', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + // Need to be explicit with the returned type because TS struggles to identify it + fetch: (): { testPass?: Array<{ name: string; value: number }>; otherProp?: number } => { + if (Math.random() > 0.5) { + return { testPass: [{ name: 'a', value: 100 }] }; + } + return { otherProp: 1 }; + }, + // @ts-expect-error + schema: { + testPass: { name: { type: 'keyword' }, value: { type: 'long' } }, + }, + }); + expect(collector).toBeDefined(); + }); + + test('schema defines all the optional properties', () => { + const collector = new Collector(logger, { + type: 'my_test_collector', + isReady: () => false, + // Need to be explicit with the returned type because TS struggles to identify it + fetch: (): { testPass?: Array<{ name: string; value: number }>; otherProp?: number } => { + if (Math.random() > 0.5) { + return { testPass: [{ name: 'a', value: 100 }] }; + } + return { otherProp: 1 }; + }, + schema: { + testPass: { name: { type: 'keyword' }, value: { type: 'long' } }, + otherProp: { type: 'long' }, + }, + }); + expect(collector).toBeDefined(); + }); + }); +}); diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 9ae63b9f50e42..d57700024c088 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -34,20 +34,20 @@ export interface SchemaField { type: string; } -type Purify = { [P in T]: T }[T]; +export type RecursiveMakeSchemaFrom = U extends object + ? MakeSchemaFrom + : { type: AllowedSchemaTypes }; export type MakeSchemaFrom = { - [Key in Purify>]: Base[Key] extends Array - ? { type: AllowedSchemaTypes } - : Base[Key] extends object - ? MakeSchemaFrom - : { type: AllowedSchemaTypes }; + [Key in keyof Base]: Base[Key] extends Array + ? RecursiveMakeSchemaFrom + : RecursiveMakeSchemaFrom; }; export interface CollectorOptions { type: string; init?: Function; - schema?: MakeSchemaFrom; + schema?: MakeSchemaFrom>; // Using Required to enforce all optional keys in the object fetch: (callCluster: LegacyAPICaller) => Promise | T; /* * A hook for allowing the fetched data payload to be organized into a typed From c815c969373ad472cf3ebc1382fc3848e71ec3aa Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Wed, 8 Jul 2020 10:01:00 -0700 Subject: [PATCH 06/10] Multi-line kql bar (#70140) * Multiline kql bar * fix id * use visibility rather than display to hide stuff, cross fingers for tests * another vis trick for tests * quasi fix tests, still some failures * caroline feedback * fun! * fix for mouse * fix test * check api * fix unit test on query_string_input * Fix cypress test * handle the resize of the height of the textarea when the window have been resize Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Elastic Machine Co-authored-by: Liza K --- ...in-plugins-data-public.querystringinput.md | 2 +- src/plugins/data/public/public.api.md | 3 +- .../ui/query_string_input/_query_bar.scss | 44 +++++ .../query_string_input/language_switcher.tsx | 2 +- .../query_string_input/query_bar_top_row.tsx | 13 +- .../query_string_input.test.tsx | 12 +- .../query_string_input/query_string_input.tsx | 174 ++++++++++++------ .../data/public/ui/typeahead/_suggestion.scss | 9 +- test/functional/apps/discover/_discover.js | 2 + .../cypress/integration/cases.spec.ts | 2 +- .../integration/ml_conditional_links.spec.ts | 73 ++++---- .../cypress/integration/url_state.spec.ts | 7 +- .../cypress/tasks/create_new_rule.ts | 4 +- .../cypress/tasks/timeline.ts | 2 +- .../components/query_bar/index.test.tsx | 11 +- 15 files changed, 237 insertions(+), 123 deletions(-) diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md index b168602b64927..e139b326b7500 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md @@ -7,5 +7,5 @@ Signature: ```typescript -QueryStringInput: React.FC> +QueryStringInput: React.FC> ``` diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 340a378b946ec..c8110dbfd0041 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -51,7 +51,6 @@ import { ErrorToastOptions } from 'src/core/public/notifications'; import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiComboBoxProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; -import { EuiFieldText } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { ExclusiveUnion } from '@elastic/eui'; import { ExistsParams } from 'elasticsearch'; @@ -1482,7 +1481,7 @@ export interface QueryState { // Warning: (ae-missing-release-tag) "QueryStringInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const QueryStringInput: React.FC>; +export const QueryStringInput: React.FC>; // @public (undocumented) export type QuerySuggestion = QuerySuggestionBasic | QuerySuggestionField; diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index f95fe748dfdae..007be9da63e49 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -1,3 +1,41 @@ +.kbnQueryBar__wrap { + max-width: 100%; + z-index: $euiZContentMenu; +} + +// Uses the append style, but no bordering +.kqlQueryBar__languageSwitcherButton { + border-right: none !important; +} + +.kbnQueryBar__textarea { + z-index: $euiZContentMenu; + resize: none !important; // When in the group, it will autosize + height: $euiSizeXXL; + // Unlike most inputs within layout control groups, the text area still needs a border. + // These adjusts help it sit above the control groups shadow to line up correctly. + padding-top: $euiSizeS + 3px !important; + transform: translateY(-2px); + padding: $euiSizeS - 1px; + + &:not(:focus) { + @include euiYScrollWithShadows; + white-space: nowrap; + overflow-y: hidden; + overflow-x: hidden; + border: none; + box-shadow: none; + } + + // When focused, let it scroll + &:focus { + overflow-x: auto; + overflow-y: auto; + width: calc(100% + 1px); // To overtake the group's fake border + white-space: normal; + } +} + @include euiBreakpoint('xs', 's') { .kbnQueryBar--withDatePicker { > :first-child { @@ -16,5 +54,11 @@ // sass-lint:disable-block no-important flex-grow: 0 !important; flex-basis: auto !important; + margin-right: -$euiSizeXS !important; + + &.kbnQueryBar__datePickerWrapper-isHidden { + width: 0; + overflow: hidden; + } } } diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index a4c93d0044c9a..4d51b173f6743 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -60,7 +60,7 @@ export function QueryLanguageSwitcher(props: Props) { setIsPopoverOpen(!isPopoverOpen)} - className="euiFormControlLayout__append" + className="euiFormControlLayout__append kqlQueryBar__languageSwitcherButton" data-test-subj={'switchQueryLanguageButton'} > {props.language === 'lucene' ? luceneLabel : kqlLabel} diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index 4b0dc579c39ce..86bf30ba0e374 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -69,6 +69,7 @@ interface Props { export function QueryBarTopRow(props: Props) { const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); + const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); const kibana = useKibana(); const { uiSettings, notifications, storage, appName, docLinks } = kibana.services; @@ -107,6 +108,10 @@ export function QueryBarTopRow(props: Props) { }); } + function onChangeQueryInputFocus(isFocused: boolean) { + setIsQueryInputFocused(isFocused); + } + function onTimeChange({ start, end, @@ -182,6 +187,7 @@ export function QueryBarTopRow(props: Props) { query={props.query!} screenTitle={props.screenTitle} onChange={onQueryChange} + onChangeQueryInputFocus={onChangeQueryInputFocus} onSubmit={onInputSubmit} persistedLog={persistedLog} dataTestSubj={props.dataTestSubj} @@ -268,8 +274,12 @@ export function QueryBarTopRow(props: Props) { }; }); + const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { + 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, + }); + return ( - + ); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx index 755716aee8f48..0397c34d0c2b8 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx @@ -23,7 +23,7 @@ import { mockPersistedLogFactory, } from './query_string_input.test.mocks'; -import { EuiFieldText } from '@elastic/eui'; +import { EuiTextArea } from '@elastic/eui'; import React from 'react'; import { QueryLanguageSwitcher } from './language_switcher'; import { QueryStringInput, QueryStringInputUI } from './query_string_input'; @@ -102,7 +102,7 @@ describe('QueryStringInput', () => { indexPatterns: [stubIndexPatternWithFields], }) ); - expect(component.find(EuiFieldText).props().value).toBe(kqlQuery.query); + expect(component.find(EuiTextArea).props().value).toBe(kqlQuery.query); expect(component.find(QueryLanguageSwitcher).prop('language')).toBe(kqlQuery.language); }); @@ -117,7 +117,7 @@ describe('QueryStringInput', () => { expect(component.find(QueryLanguageSwitcher).prop('language')).toBe(luceneQuery.language); }); - it('Should disable autoFocus on EuiFieldText when disableAutoFocus prop is true', () => { + it('Should disable autoFocus on EuiTextArea when disableAutoFocus prop is true', () => { const component = mount( wrapQueryStringInputInContext({ query: kqlQuery, @@ -126,7 +126,7 @@ describe('QueryStringInput', () => { disableAutoFocus: true, }) ); - expect(component.find(EuiFieldText).prop('autoFocus')).toBeFalsy(); + expect(component.find(EuiTextArea).prop('autoFocus')).toBeFalsy(); }); it('Should create a unique PersistedLog based on the appName and query language', () => { @@ -179,7 +179,7 @@ describe('QueryStringInput', () => { const instance = component.find('QueryStringInputUI').instance() as QueryStringInputUI; const input = instance.inputRef; - const inputWrapper = component.find(EuiFieldText).find('input'); + const inputWrapper = component.find(EuiTextArea).find('textarea'); inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true }); expect(mockCallback).toHaveBeenCalledTimes(1); @@ -199,7 +199,7 @@ describe('QueryStringInput', () => { const instance = component.find('QueryStringInputUI').instance() as QueryStringInputUI; const input = instance.inputRef; - const inputWrapper = component.find(EuiFieldText).find('input'); + const inputWrapper = component.find(EuiTextArea).find('textarea'); inputWrapper.simulate('keyDown', { target: input, keyCode: 13, key: 'Enter', metaKey: true }); expect(mockPersistedLog.add).toHaveBeenCalledWith('response:200'); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index c746449f14c26..6f72aa829d8f3 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -22,13 +22,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiFieldText, + EuiTextArea, EuiOutsideClickDetector, PopoverAnchorPosition, EuiFlexGroup, EuiFlexItem, EuiButton, EuiLink, + htmlIdGenerator, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -49,13 +50,14 @@ interface Props { query: Query; disableAutoFocus?: boolean; screenTitle?: string; - prepend?: React.ComponentProps['prepend']; + prepend?: any; persistedLog?: PersistedLog; bubbleSubmitEvent?: boolean; placeholder?: string; languageSwitcherPopoverAnchorPosition?: PopoverAnchorPosition; onBlur?: () => void; onChange?: (query: Query) => void; + onChangeQueryInputFocus?: (isFocused: boolean) => void; onSubmit?: (query: Query) => void; dataTestSubj?: string; } @@ -93,7 +95,7 @@ export class QueryStringInputUI extends Component { indexPatterns: [], }; - public inputRef: HTMLInputElement | null = null; + public inputRef: HTMLTextAreaElement | null = null; private persistedLog: PersistedLog | undefined; private abortController?: AbortController; @@ -223,27 +225,32 @@ export class QueryStringInputUI extends Component { this.onChange({ query: value, language: this.props.query.language }); }; - private onInputChange = (event: React.ChangeEvent) => { + private onInputChange = (event: React.ChangeEvent) => { this.onQueryStringChange(event.target.value); + if (event.target.value === '') { + this.handleRemoveHeight(); + } else { + this.handleAutoHeight(); + } }; - private onClickInput = (event: React.MouseEvent) => { - if (event.target instanceof HTMLInputElement) { + private onClickInput = (event: React.MouseEvent) => { + if (event.target instanceof HTMLTextAreaElement) { this.onQueryStringChange(event.target.value); } }; - private onKeyUp = (event: React.KeyboardEvent) => { + private onKeyUp = (event: React.KeyboardEvent) => { if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) { this.setState({ isSuggestionsVisible: true }); - if (event.target instanceof HTMLInputElement) { + if (event.target instanceof HTMLTextAreaElement) { this.onQueryStringChange(event.target.value); } } }; - private onKeyDown = (event: React.KeyboardEvent) => { - if (event.target instanceof HTMLInputElement) { + private onKeyDown = (event: React.KeyboardEvent) => { + if (event.target instanceof HTMLTextAreaElement) { const { isSuggestionsVisible, index } = this.state; const preventDefault = event.preventDefault.bind(event); const { target, key, metaKey } = event; @@ -258,16 +265,19 @@ export class QueryStringInputUI extends Component { switch (event.keyCode) { case KEY_CODES.DOWN: - event.preventDefault(); if (isSuggestionsVisible && index !== null) { + event.preventDefault(); this.incrementIndex(index); - } else { + // Note to engineers. `isSuggestionVisible` does not mean the suggestions are visible. + // This should likely be fixed, it's more that suggestions can be shown. + } else if ((isSuggestionsVisible && index == null) || this.getQueryString() === '') { + event.preventDefault(); this.setState({ isSuggestionsVisible: true, index: 0 }); } break; case KEY_CODES.UP: - event.preventDefault(); if (isSuggestionsVisible && index !== null) { + event.preventDefault(); this.decrementIndex(index); } break; @@ -439,6 +449,17 @@ export class QueryStringInputUI extends Component { if (this.state.isSuggestionsVisible) { this.setState({ isSuggestionsVisible: false, index: null }); } + this.handleBlurHeight(); + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(false); + } + }; + + private onInputBlur = () => { + this.handleBlurHeight(); + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(false); + } }; private onClickSuggestion = (suggestion: QuerySuggestion) => { @@ -460,6 +481,8 @@ export class QueryStringInputUI extends Component { this.setState({ index }); }; + textareaId = htmlIdGenerator()(); + public componentDidMount() { const parsedQuery = fromUser(toUser(this.props.query.query)); if (!isEqual(this.props.query.query, parsedQuery)) { @@ -468,6 +491,8 @@ export class QueryStringInputUI extends Component { this.initPersistedLog(); this.fetchIndexPatterns().then(this.updateSuggestions); + + window.addEventListener('resize', this.handleAutoHeight); } public componentDidUpdate(prevProps: Props) { @@ -485,15 +510,18 @@ export class QueryStringInputUI extends Component { } if (this.state.selectionStart !== null && this.state.selectionEnd !== null) { - if (this.inputRef) { - // For some reason the type guard above does not make the compiler happy - // @ts-ignore + if (this.inputRef != null) { this.inputRef.setSelectionRange(this.state.selectionStart, this.state.selectionEnd); } this.setState({ selectionStart: null, selectionEnd: null, }); + if (document.activeElement !== null && document.activeElement.id === this.textareaId) { + this.handleAutoHeight(); + } else { + this.handleRemoveHeight(); + } } } @@ -501,8 +529,37 @@ export class QueryStringInputUI extends Component { if (this.abortController) this.abortController.abort(); this.updateSuggestions.cancel(); this.componentIsUnmounting = true; + window.removeEventListener('resize', this.handleAutoHeight); } + handleAutoHeight = () => { + if (this.inputRef !== null && document.activeElement === this.inputRef) { + this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important'); + } + }; + + handleRemoveHeight = () => { + if (this.inputRef !== null) { + this.inputRef.style.removeProperty('height'); + } + }; + + handleBlurHeight = () => { + if (this.inputRef !== null) { + this.handleRemoveHeight(); + this.inputRef.scrollTop = 0; + } + }; + + handleOnFocus = () => { + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(true); + } + requestAnimationFrame(() => { + this.handleAutoHeight(); + }); + }; + public render() { const isSuggestionsVisible = this.state.isSuggestionsVisible && { 'aria-controls': 'kbnTypeahead__items', @@ -511,20 +568,24 @@ export class QueryStringInputUI extends Component { const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; return ( - -
-
-
- + {this.props.prepend} + +
+
+ { onKeyUp={this.onKeyUp} onChange={this.onInputChange} onClick={this.onClickInput} - onBlur={this.props.onBlur} + onBlur={this.onInputBlur} + onFocus={this.handleOnFocus} + className="kbnQueryBar__textarea" fullWidth - autoFocus={!this.props.disableAutoFocus} - inputRef={(node) => { + rows={1} + id={this.textareaId} + autoFocus={ + this.props.onChangeQueryInputFocus ? false : !this.props.disableAutoFocus + } + inputRef={(node: any) => { if (node) { this.inputRef = node; } @@ -550,7 +617,6 @@ export class QueryStringInputUI extends Component { defaultMessage: 'Start typing to search and filter the {pageType} page', values: { pageType: this.services.appName }, })} - type="text" aria-autocomplete="list" aria-controls={this.state.isSuggestionsVisible ? 'kbnTypeahead__items' : undefined} aria-activedescendant={ @@ -559,29 +625,29 @@ export class QueryStringInputUI extends Component { : undefined } role="textbox" - prepend={this.props.prepend} - append={ - - } data-test-subj={this.props.dataTestSubj || 'queryInput'} - /> + > + {this.getQueryString()} +
-
- -
- + +
+ + + +
); } } diff --git a/src/plugins/data/public/ui/typeahead/_suggestion.scss b/src/plugins/data/public/ui/typeahead/_suggestion.scss index 3a215ceddcd00..81c05f1a8a78c 100644 --- a/src/plugins/data/public/ui/typeahead/_suggestion.scss +++ b/src/plugins/data/public/ui/typeahead/_suggestion.scss @@ -16,7 +16,7 @@ $kbnTypeaheadTypes: ( color: $euiTextColor; background-color: $euiColorEmptyShade; position: absolute; - top: -1px; + top: -2px; z-index: $euiZContentMenu; width: 100%; border-bottom-left-radius: $euiBorderRadius; @@ -56,7 +56,6 @@ $kbnTypeaheadTypes: ( .kbnTypeahead__item.active { background-color: $euiColorLightestShade; - .kbnSuggestionItem__callout { background: $euiColorEmptyShade; } @@ -130,7 +129,6 @@ $kbnTypeaheadTypes: ( align-items: center; } - .kbnSuggestionItem__text { flex-grow: 0; /* 2 */ flex-basis: auto; /* 2 */ @@ -142,16 +140,15 @@ $kbnTypeaheadTypes: ( color: $euiTextColor; } - .kbnSuggestionItem__description { color: $euiColorDarkShade; overflow: hidden; text-overflow: ellipsis; margin-left: $euiSizeXL; - + &:empty { flex-grow: 0; - margin-left:0; + margin-left: 0; } } diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 906f0b83e99e7..949a01ff7873a 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -218,6 +218,8 @@ export default function ({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); await queryBar.setQuery(''); + // To remove focus of the of the search bar so date/time picker can show + await PageObjects.discover.selectIndexPattern(defaultSettings.defaultIndex); await PageObjects.timePicker.setDefaultAbsoluteRange(); log.debug( diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index efd9ece8aec56..9438c28f05fef 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -99,6 +99,6 @@ describe('Cases', () => { cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title); cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description); - cy.get(TIMELINE_QUERY).should('have.attr', 'value', case1.timeline.query); + cy.get(TIMELINE_QUERY).invoke('text').should('eq', case1.timeline.query); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/ml_conditional_links.spec.ts b/x-pack/plugins/security_solution/cypress/integration/ml_conditional_links.spec.ts index 0c3424576e4cf..6b3fc9e751ea4 100644 --- a/x-pack/plugins/security_solution/cypress/integration/ml_conditional_links.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/ml_conditional_links.spec.ts @@ -27,74 +27,67 @@ import { describe('ml conditional links', () => { it('sets the KQL from a single IP with a value for the query', () => { loginAndWaitForPageWithoutDateRange(mlNetworkSingleIpKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(process.name: "conhost.exe" or process.name: "sc.exe")' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should('eq', '(process.name: "conhost.exe" or process.name: "sc.exe")'); }); it('sets the KQL from a multiple IPs with a null for the query', () => { loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpNullKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1") or (source.ip: "127.0.0.2" or destination.ip: "127.0.0.2"))' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should( + 'eq', + '((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1") or (source.ip: "127.0.0.2" or destination.ip: "127.0.0.2"))' + ); }); it('sets the KQL from a multiple IPs with a value for the query', () => { loginAndWaitForPageWithoutDateRange(mlNetworkMultipleIpKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1") or (source.ip: "127.0.0.2" or destination.ip: "127.0.0.2")) and ((process.name: "conhost.exe" or process.name: "sc.exe"))' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should( + 'eq', + '((source.ip: "127.0.0.1" or destination.ip: "127.0.0.1") or (source.ip: "127.0.0.2" or destination.ip: "127.0.0.2")) and ((process.name: "conhost.exe" or process.name: "sc.exe"))' + ); }); it('sets the KQL from a $ip$ with a value for the query', () => { loginAndWaitForPageWithoutDateRange(mlNetworkKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(process.name: "conhost.exe" or process.name: "sc.exe")' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should('eq', '(process.name: "conhost.exe" or process.name: "sc.exe")'); }); it('sets the KQL from a single host name with a value for query', () => { loginAndWaitForPageWithoutDateRange(mlHostSingleHostKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(process.name: "conhost.exe" or process.name: "sc.exe")' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should('eq', '(process.name: "conhost.exe" or process.name: "sc.exe")'); }); it('sets the KQL from a multiple host names with null for query', () => { loginAndWaitForPageWithoutDateRange(mlHostMultiHostNullKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(host.name: "siem-windows" or host.name: "siem-suricata")' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should('eq', '(host.name: "siem-windows" or host.name: "siem-suricata")'); }); it('sets the KQL from a multiple host names with a value for query', () => { loginAndWaitForPageWithoutDateRange(mlHostMultiHostKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(host.name: "siem-windows" or host.name: "siem-suricata") and ((process.name: "conhost.exe" or process.name: "sc.exe"))' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should( + 'eq', + '(host.name: "siem-windows" or host.name: "siem-suricata") and ((process.name: "conhost.exe" or process.name: "sc.exe"))' + ); }); it('sets the KQL from a undefined/null host name but with a value for query', () => { loginAndWaitForPageWithoutDateRange(mlHostVariableHostKqlQuery); - cy.get(KQL_INPUT).should( - 'have.attr', - 'value', - '(process.name: "conhost.exe" or process.name: "sc.exe")' - ); + cy.get(KQL_INPUT) + .invoke('text') + .should('eq', '(process.name: "conhost.exe" or process.name: "sc.exe")'); }); it('redirects from a single IP with a null for the query', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index a3a927cbea7d4..81af9ece9ed45 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -154,12 +154,12 @@ describe('url state', () => { it('sets kql on network page', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlNetworkNetwork); - cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"'); + cy.get(KQL_INPUT).invoke('text').should('eq', 'source.ip: "10.142.0.9"'); }); it('sets kql on hosts page', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts); - cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"'); + cy.get(KQL_INPUT).invoke('text').should('eq', 'source.ip: "10.142.0.9"'); }); it('sets the url state when kql is set', () => { @@ -230,8 +230,7 @@ describe('url state', () => { it('Do not clears kql when navigating to a new page', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts); navigateFromHeaderTo(NETWORK); - - cy.get(KQL_INPUT).should('have.attr', 'value', 'source.ip: "10.142.0.9"'); + cy.get(KQL_INPUT).invoke('text').should('eq', 'source.ip: "10.142.0.9"'); }); it.skip('sets and reads the url state for timeline by id', () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index eca5885e7b3d9..88ae582b58891 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -82,7 +82,7 @@ export const fillAboutRuleAndContinue = (rule: CustomRule | MachineLearningRule) export const fillDefineCustomRuleAndContinue = (rule: CustomRule) => { cy.get(CUSTOM_QUERY_INPUT).type(rule.customQuery); - cy.get(CUSTOM_QUERY_INPUT).should('have.attr', 'value', rule.customQuery); + cy.get(CUSTOM_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); @@ -91,7 +91,7 @@ export const fillDefineCustomRuleAndContinue = (rule: CustomRule) => { export const fillDefineCustomRuleWithImportedQueryAndContinue = (rule: CustomRule) => { cy.get(IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK).click(); cy.get(TIMELINE(rule.timelineId)).click(); - cy.get(CUSTOM_QUERY_INPUT).should('have.attr', 'value', rule.customQuery); + cy.get(CUSTOM_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 9e17433090c2b..761fd2c1e6a0b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -58,7 +58,7 @@ export const createNewTimeline = () => { }; export const executeTimelineKQL = (query: string) => { - cy.get(`${SEARCH_OR_FILTER_CONTAINER} input`).type(`${query} {enter}`); + cy.get(`${SEARCH_OR_FILTER_CONTAINER} textarea`).type(`${query} {enter}`); }; export const expandFirstTimelineEventDetails = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx index a3cab1cfabd71..aac83ce650d86 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.test.tsx @@ -214,15 +214,18 @@ describe('QueryBar ', () => { /> ); - const queryInput = wrapper.find(QueryBar).find('input[data-test-subj="queryInput"]'); + let queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); queryInput.simulate('change', { target: { value: 'host.name:*' } }); - expect(queryInput.html()).toContain('value="host.name:*"'); + wrapper.update(); + queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); + expect(queryInput.props().children).toBe('host.name:*'); wrapper.setProps({ filterQueryDraft: null }); wrapper.update(); + queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); - expect(queryInput.html()).toContain('value=""'); + expect(queryInput.props().children).toBe(''); }); }); @@ -258,7 +261,7 @@ describe('QueryBar ', () => { const onSubmitQueryRef = searchBarProps.onQuerySubmit; const onSavedQueryRef = searchBarProps.onSavedQueryUpdated; - const queryInput = wrapper.find(QueryBar).find('input[data-test-subj="queryInput"]'); + const queryInput = wrapper.find(QueryBar).find('textarea[data-test-subj="queryInput"]'); queryInput.simulate('change', { target: { value: 'hello: world' } }); wrapper.update(); From 54bd07f81b6fc75ede018b9632e06c5bce616c4c Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 8 Jul 2020 10:41:09 -0700 Subject: [PATCH 07/10] temporarily disable firefox functional tests in PRs (#71116) Co-authored-by: spalger --- vars/tasks.groovy | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 9de4c78322d3e..3ff9a7b4850ae 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -42,7 +42,13 @@ def test() { } def functionalOss(Map params = [:]) { - def config = params ?: [ciGroups: true, firefox: true, accessibility: true, pluginFunctional: true, visualRegression: false] + def config = params ?: [ + ciGroups: true, + firefox: !githubPr.isPr(), + accessibility: true, + pluginFunctional: true, + visualRegression: false + ] task { kibanaPipeline.buildOss(6) @@ -73,7 +79,7 @@ def functionalOss(Map params = [:]) { def functionalXpack(Map params = [:]) { def config = params ?: [ ciGroups: true, - firefox: true, + firefox: !githubPr.isPr(), accessibility: true, pluginFunctional: true, savedObjectsFieldMetrics: true, From 43302bd0b6a145cfec831bb4859636e3694ca566 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 8 Jul 2020 11:54:03 -0600 Subject: [PATCH 08/10] [QA] stack integration tests (not run in ci) (#70904) ## Summary Migrate tests from integration-test repo. The integration-test repo's purpose is to smoke test the build artifacts of all the main products in the stack (the .deb, .rpm, .tar.gz, .zip files). Currently Vagrant and VirtualBox are used to create VMs of the OSs appropriate for installing those build artifacts. These scripts are in the integration-test repo. After the VMs are installed and running the stack, a small number of UI tests are run against Kibana to verify we have beats data, logstash data, etc. Kibana-QA team also uses the various VMs for manual testing since manually setting up security across the full stack can be time consuming. The new tests in this PR under x-pack/test/stack_functional_integration/ are NOT executed as part of Kibana CI process. They run from other periodic Jenkins jobs. Co-authored-by: Elastic Machine Co-authored-by: LeeDr --- test/functional/services/remote/webdriver.ts | 38 +++- .../page_objects/monitoring_page.js | 6 + .../configs/build_state.js | 18 ++ ...onfig.stack_functional_integration_base.js | 62 +++++++ ...ig.stack_functional_integration_base_ie.js | 18 ++ .../configs/tests_list.js | 56 ++++++ .../test/functional/apps/ccs/ccs.js | 175 ++++++++++++++++++ .../test/functional/apps/ccs/index.js | 11 ++ .../test/functional/apps/filebeat/filebeat.js | 24 +++ .../test/functional/apps/filebeat/index.js | 11 ++ .../functional/apps/heartbeat/_heartbeat.js | 23 +++ .../test/functional/apps/heartbeat/index.js | 12 ++ .../apps/management/_index_pattern_create.js | 64 +++++++ .../test/functional/apps/management/index.js | 11 ++ .../functional/apps/metricbeat/_metricbeat.js | 34 ++++ .../test/functional/apps/metricbeat/index.js | 11 ++ .../functional/apps/monitoring/_monitoring.js | 40 ++++ .../test/functional/apps/monitoring/index.js | 11 ++ .../functional/apps/packetbeat/_packetbeat.js | 38 ++++ .../test/functional/apps/packetbeat/index.js | 11 ++ .../test/functional/apps/reporting/index.js | 18 ++ .../apps/reporting/reporting_watcher.js | 94 ++++++++++ .../apps/reporting/reporting_watcher_png.js | 88 +++++++++ .../test/functional/apps/reporting/util.js | 77 ++++++++ .../functional/apps/sample_data/e_commerce.js | 29 +++ .../test/functional/apps/sample_data/index.js | 11 ++ .../functional/apps/telemetry/_telemetry.js | 31 ++++ .../test/functional/apps/telemetry/index.js | 12 ++ .../functional/apps/winlogbeat/_winlogbeat.js | 33 ++++ .../test/functional/apps/winlogbeat/index.js | 11 ++ 30 files changed, 1068 insertions(+), 10 deletions(-) create mode 100644 x-pack/test/stack_functional_integration/configs/build_state.js create mode 100644 x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js create mode 100644 x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js create mode 100644 x-pack/test/stack_functional_integration/configs/tests_list.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/ccs/ccs.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/ccs/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/filebeat/filebeat.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/filebeat/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/_heartbeat.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/management/_index_pattern_create.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/management/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/_metricbeat.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/monitoring/_monitoring.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/monitoring/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/_packetbeat.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/reporting/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher_png.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/reporting/util.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/sample_data/e_commerce.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/sample_data/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/telemetry/_telemetry.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/telemetry/index.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/_winlogbeat.js create mode 100644 x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/index.js diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 27814060e70c1..78f659a064a0c 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -88,6 +88,7 @@ async function attemptToCreateCommand( ) { const attemptId = ++attemptCounter; log.debug('[webdriver] Creating session'); + const remoteSessionUrl = process.env.REMOTE_SESSION_URL; const buildDriverInstance = async () => { switch (browserType) { @@ -133,11 +134,20 @@ async function attemptToCreateCommand( chromeCapabilities.set('goog:loggingPrefs', { browser: 'ALL' }); chromeCapabilities.setAcceptInsecureCerts(config.acceptInsecureCerts); - const session = await new Builder() - .forBrowser(browserType) - .withCapabilities(chromeCapabilities) - .setChromeService(new chrome.ServiceBuilder(chromeDriver.path).enableVerboseLogging()) - .build(); + let session; + if (remoteSessionUrl) { + session = await new Builder() + .forBrowser(browserType) + .withCapabilities(chromeCapabilities) + .usingServer(remoteSessionUrl) + .build(); + } else { + session = await new Builder() + .forBrowser(browserType) + .withCapabilities(chromeCapabilities) + .setChromeService(new chrome.ServiceBuilder(chromeDriver.path).enableVerboseLogging()) + .build(); + } return { session, @@ -284,11 +294,19 @@ async function attemptToCreateCommand( logLevel: 'TRACE', }); - const session = await new Builder() - .forBrowser(browserType) - .withCapabilities(ieCapabilities) - .build(); - + let session; + if (remoteSessionUrl) { + session = await new Builder() + .forBrowser(browserType) + .withCapabilities(ieCapabilities) + .usingServer(remoteSessionUrl) + .build(); + } else { + session = await new Builder() + .forBrowser(browserType) + .withCapabilities(ieCapabilities) + .build(); + } return { session, consoleLog$: Rx.EMPTY, diff --git a/x-pack/test/functional/page_objects/monitoring_page.js b/x-pack/test/functional/page_objects/monitoring_page.js index ece0c0a6c7854..c3b9d20b3ac4a 100644 --- a/x-pack/test/functional/page_objects/monitoring_page.js +++ b/x-pack/test/functional/page_objects/monitoring_page.js @@ -8,6 +8,7 @@ export function MonitoringPageProvider({ getPageObjects, getService }) { const PageObjects = getPageObjects(['common', 'header', 'security', 'login', 'spaceSelector']); const testSubjects = getService('testSubjects'); const security = getService('security'); + const find = getService('find'); return new (class MonitoringPage { async navigateTo(useSuperUser = false) { @@ -25,6 +26,11 @@ export function MonitoringPageProvider({ getPageObjects, getService }) { await PageObjects.common.navigateToApp('monitoring'); } + async getWelcome() { + const el = await find.byCssSelector('.euiCallOut--primary', 10000 * 10); + return await el.getVisibleText(); + } + async getAccessDeniedMessage() { return testSubjects.getVisibleText('accessDeniedTitle'); } diff --git a/x-pack/test/stack_functional_integration/configs/build_state.js b/x-pack/test/stack_functional_integration/configs/build_state.js new file mode 100644 index 0000000000000..abf1bff56331a --- /dev/null +++ b/x-pack/test/stack_functional_integration/configs/build_state.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import dotEnv from 'dotenv'; +import testsList from './tests_list'; + +// envObj :: path -> {} +const envObj = (path) => dotEnv.config({ path }); + +// default fn :: path -> {} +export default (path) => { + const obj = envObj(path).parsed; + return { tests: testsList(obj), ...obj }; +}; diff --git a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js new file mode 100644 index 0000000000000..a34d158496ba0 --- /dev/null +++ b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import buildState from './build_state'; +import { ToolingLog } from '@kbn/dev-utils'; +import chalk from 'chalk'; +import { esTestConfig, kbnTestConfig } from '@kbn/test'; + +const reportName = 'Stack Functional Integration Tests'; +const testsFolder = '../test/functional/apps'; +const stateFilePath = '../../../../../integration-test/qa/envvars.sh'; +const prepend = (testFile) => require.resolve(`${testsFolder}/${testFile}`); +const log = new ToolingLog({ + level: 'info', + writeTo: process.stdout, +}); + +export default async ({ readConfigFile }) => { + const defaultConfigs = await readConfigFile(require.resolve('../../functional/config')); + const { tests, ...provisionedConfigs } = buildState(resolve(__dirname, stateFilePath)); + + const servers = { + kibana: kbnTestConfig.getUrlParts(), + elasticsearch: esTestConfig.getUrlParts(), + }; + log.info(`servers data: ${JSON.stringify(servers)}`); + const settings = { + ...defaultConfigs.getAll(), + junit: { + reportName: `${reportName} - ${provisionedConfigs.VM}`, + }, + servers, + testFiles: tests.map(prepend).map(logTest), + // testFiles: ['monitoring'].map(prepend).map(logTest), + // If we need to do things like disable animations, we can do it in configure_start_kibana.sh, in the provisioner...which lives in the integration-test private repo + uiSettings: {}, + security: { disableTestUser: true }, + }; + return settings; +}; + +// Returns index 1 from the resulting array-like. +const splitRight = (re) => (testPath) => re.exec(testPath)[1]; + +function truncate(testPath) { + const dropKibanaPath = splitRight(/^.+kibana[\\/](.*$)/gm); + return dropKibanaPath(testPath); +} +function highLight(testPath) { + const dropTestsPath = splitRight(/^.+test[\\/]functional[\\/]apps[\\/](.*)[\\/]/gm); + const cleaned = dropTestsPath(testPath); + const colored = chalk.greenBright.bold(cleaned); + return testPath.replace(cleaned, colored); +} +function logTest(testPath) { + log.info(`Testing: '${highLight(truncate(testPath))}'`); + return testPath; +} diff --git a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js new file mode 100644 index 0000000000000..933a59e4e25b9 --- /dev/null +++ b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base_ie.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default async ({ readConfigFile }) => { + const baseConfigs = await readConfigFile( + require.resolve('./config.stack_functional_integration_base.js') + ); + return { + ...baseConfigs.getAll(), + browser: { + type: 'ie', + }, + security: { disableTestUser: true }, + }; +}; diff --git a/x-pack/test/stack_functional_integration/configs/tests_list.js b/x-pack/test/stack_functional_integration/configs/tests_list.js new file mode 100644 index 0000000000000..ff68cb6285965 --- /dev/null +++ b/x-pack/test/stack_functional_integration/configs/tests_list.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// testsList :: {} -> list +export default (envObj) => { + const xs = []; + // one of these 2 needs to create the default index pattern + if (envObj.PRODUCTS.includes('logstash')) { + xs.push('management'); + } else { + xs.push('sample_data'); + } + + // get the opt in/out banner out of the way early + if (envObj.XPACK === 'YES') { + xs.push('telemetry'); + } + + if (envObj.BEATS.includes('metricbeat')) { + xs.push('metricbeat'); + } + if (envObj.BEATS.includes('filebeat')) { + xs.push('filebeat'); + } + if (envObj.BEATS.includes('packetbeat')) { + xs.push('packetbeat'); + } + if (envObj.BEATS.includes('winlogbeat')) { + xs.push('winlogbeat'); + } + if (envObj.BEATS.includes('heartbeat')) { + xs.push('heartbeat'); + } + if (envObj.VM === 'ubuntu16_tar_ccs') { + xs.push('ccs'); + } + + // with latest elasticsearch Js client, we can only run these watcher tests + // which use the watcher API on a config with x-pack but without TLS (no security) + if (envObj.VM === 'ubuntu16_tar') { + xs.push('reporting'); + } + + if (envObj.XPACK === 'YES' && ['TRIAL', 'GOLD', 'PLATINUM'].includes(envObj.LICENSE)) { + // we can't test enabling monitoring on this config because we already enable it through cluster settings for both clusters. + if (envObj.VM !== 'ubuntu16_tar_ccs') { + // monitoring is last because we switch to the elastic superuser here + xs.push('monitoring'); + } + } + + return xs; +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/ccs/ccs.js b/x-pack/test/stack_functional_integration/test/functional/apps/ccs/ccs.js new file mode 100644 index 0000000000000..a952824d8db61 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/ccs/ccs.js @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default ({ getService, getPageObjects }) => { + describe('Cross cluster search test', async () => { + const PageObjects = getPageObjects([ + 'common', + 'settings', + 'discover', + 'security', + 'header', + 'timePicker', + ]); + const retry = getService('retry'); + const log = getService('log'); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + const kibanaServer = getService('kibanaServer'); + + before(async () => { + await browser.setWindowSize(1200, 800); + // pincking relative time in timepicker isn't working. This is also faster. + // It's the default set, plus new "makelogs" +/- 3 days from now + await kibanaServer.uiSettings.replace({ + 'timepicker:quickRanges': `[ + { + "from": "now-3d", + "to": "now+3d", + "display": "makelogs" + }, + { + "from": "now/d", + "to": "now/d", + "display": "Today" + }, + { + "from": "now/w", + "to": "now/w", + "display": "This week" + }, + { + "from": "now-15m", + "to": "now", + "display": "Last 15 minutes" + }, + { + "from": "now-30m", + "to": "now", + "display": "Last 30 minutes" + }, + { + "from": "now-1h", + "to": "now", + "display": "Last 1 hour" + }, + { + "from": "now-24h", + "to": "now", + "display": "Last 24 hours" + }, + { + "from": "now-7d", + "to": "now", + "display": "Last 7 days" + }, + { + "from": "now-30d", + "to": "now", + "display": "Last 30 days" + }, + { + "from": "now-90d", + "to": "now", + "display": "Last 90 days" + }, + { + "from": "now-1y", + "to": "now", + "display": "Last 1 year" + } + ]`, + }); + }); + + before(async () => { + if (process.env.SECURITY === 'YES') { + log.debug( + '### provisionedEnv.SECURITY === YES so log in as elastic superuser to create cross cluster indices' + ); + await PageObjects.security.logout(); + } + const url = await browser.getCurrentUrl(); + log.debug(url); + if (!url.includes('kibana')) { + await PageObjects.common.navigateToApp('management', { insertTimestamp: false }); + } else if (!url.includes('management')) { + await appsMenu.clickLink('Management'); + } + }); + + it('create local admin makelogs index pattern', async () => { + log.debug('create local admin makelogs工程 index pattern'); + await PageObjects.settings.createIndexPattern('local:makelogs工程*'); + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('local:makelogs工程*'); + }); + + it('create remote data makelogs index pattern', async () => { + log.debug('create remote data makelogs工程 index pattern'); + await PageObjects.settings.createIndexPattern('data:makelogs工程*'); + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('data:makelogs工程*'); + }); + + it('create comma separated index patterns for data and local makelogs index pattern', async () => { + log.debug( + 'create comma separated index patterns for data and local makelogs工程 index pattern' + ); + await PageObjects.settings.createIndexPattern('data:makelogs工程-*,local:makelogs工程-*'); + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('data:makelogs工程-*,local:makelogs工程-*'); + }); + + it('create index pattern for data from both clusters', async () => { + await PageObjects.settings.createIndexPattern('*:makelogs工程-*', '@timestamp', true, false); + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('*:makelogs工程-*'); + }); + + it('local:makelogs(star) should discover data from the local cluster', async () => { + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + + await PageObjects.discover.selectIndexPattern('local:makelogs工程*'); + await PageObjects.timePicker.setCommonlyUsedTime('makelogs'); + await retry.tryForTime(40000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be('14,005'); + }); + }); + + it('data:makelogs(star) should discover data from remote', async function () { + await PageObjects.discover.selectIndexPattern('data:makelogs工程*'); + await retry.tryForTime(40000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be('14,005'); + }); + }); + + it('star:makelogs-star should discover data from both clusters', async function () { + await PageObjects.discover.selectIndexPattern('*:makelogs工程-*'); + await PageObjects.timePicker.setCommonlyUsedTime('makelogs'); + await retry.tryForTime(40000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be('28,010'); + }); + }); + + it('data:makelogs-star,local:makelogs-star should discover data from both clusters', async function () { + await PageObjects.discover.selectIndexPattern('data:makelogs工程-*,local:makelogs工程-*'); + await retry.tryForTime(40000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be('28,010'); + }); + }); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/ccs/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/ccs/index.js new file mode 100644 index 0000000000000..e31a903cf0be2 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/ccs/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('ccs test', function () { + loadTestFile(require.resolve('./ccs')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/filebeat.js b/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/filebeat.js new file mode 100644 index 0000000000000..14d06ac296ba3 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/filebeat.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + describe('check filebeat', function () { + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + it('filebeat- should have hit count GT 0', async function () { + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + await PageObjects.discover.selectIndexPattern('filebeat-*'); + await PageObjects.timePicker.setCommonlyUsedTime('Last_30 days'); + await retry.try(async () => { + const hitCount = parseInt(await PageObjects.discover.getHitCount()); + expect(hitCount).to.be.greaterThan(0); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/index.js new file mode 100644 index 0000000000000..c3a81ca43a68f --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/filebeat/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('filebeat app', function () { + loadTestFile(require.resolve('./filebeat')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/_heartbeat.js b/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/_heartbeat.js new file mode 100644 index 0000000000000..4e1c02b627de0 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/_heartbeat.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'uptime']); + + describe('check heartbeat', function () { + it('Uptime app should show snapshot count greater than zero', async function () { + await PageObjects.common.navigateToApp('uptime', { insertTimestamp: false }); + + await retry.try(async function () { + const upCount = parseInt((await PageObjects.uptime.getSnapshotCount()).up); + expect(upCount).to.be.greaterThan(0); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/index.js new file mode 100644 index 0000000000000..28ae1bbaa488d --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/heartbeat/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('heartbeat app', function () { + require('./_heartbeat'); + loadTestFile(require.resolve('./_heartbeat')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/management/_index_pattern_create.js b/x-pack/test/stack_functional_integration/test/functional/apps/management/_index_pattern_create.js new file mode 100644 index 0000000000000..a43a2fce61ea1 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/management/_index_pattern_create.js @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default ({ getService, getPageObjects }) => { + describe('creating default index', function describeIndexTests() { + const PageObjects = getPageObjects(['common', 'settings']); + const retry = getService('retry'); + const log = getService('log'); + const browser = getService('browser'); + + before(async () => { + await PageObjects.common.navigateToApp('management', { insertTimestamp: false }); + await browser.setWindowSize(1200, 800); + }); + + it('create makelogs工程 index pattern', async function pageHeader() { + log.debug('create makelogs工程 index pattern'); + await PageObjects.settings.createIndexPattern('makelogs工程-*'); + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('makelogs工程-*'); + }); + + describe('create logstash index pattern', function indexPatternCreation() { + before(async () => { + await retry.tryForTime(120000, async () => { + log.debug('create Index Pattern'); + await PageObjects.settings.createIndexPattern(); + }); + }); + + it('should have index pattern in page header', async function pageHeader() { + const patternName = await PageObjects.settings.getIndexPageHeading(); + expect(patternName).to.be('logstash-*'); + }); + + it('should have expected table headers', async function checkingHeader() { + const headers = await PageObjects.settings.getTableHeader(); + log.debug('header.length = ' + headers.length); + const expectedHeaders = [ + 'Name', + 'Type', + 'Format', + 'Searchable', + 'Aggregatable', + 'Excluded', + ]; + + expect(headers.length).to.be(expectedHeaders.length); + + await Promise.all( + headers.map(async function compareHead(header, i) { + const text = await header.getVisibleText(); + expect(text).to.be(expectedHeaders[i]); + }) + ); + }); + }); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/management/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/management/index.js new file mode 100644 index 0000000000000..6e032c198bc6a --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/management/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('settings / management app', function () { + loadTestFile(require.resolve('./_index_pattern_create')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/_metricbeat.js b/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/_metricbeat.js new file mode 100644 index 0000000000000..8f6ddff180695 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/_metricbeat.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const retry = getService('retry'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const appsMenu = getService('appsMenu'); + + describe('check metricbeat', function () { + it('metricbeat- should have hit count GT 0', async function () { + const url = await browser.getCurrentUrl(); + log.debug(url); + if (!url.includes('kibana')) { + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + } else if (!url.includes('discover')) { + await appsMenu.clickLink('Discover'); + } + + await PageObjects.discover.selectIndexPattern('metricbeat-*'); + await PageObjects.timePicker.setCommonlyUsedTime('Today'); + await retry.try(async function () { + const hitCount = parseInt(await PageObjects.discover.getHitCount()); + expect(hitCount).to.be.greaterThan(0); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/index.js new file mode 100644 index 0000000000000..d45d6c835a315 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/metricbeat/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('metricbeat app', function () { + loadTestFile(require.resolve('./_metricbeat')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/_monitoring.js b/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/_monitoring.js new file mode 100644 index 0000000000000..623937b178833 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/_monitoring.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default ({ getService, getPageObjects }) => { + describe('monitoring app - stack functional integration - suite', () => { + const browser = getService('browser'); + const PageObjects = getPageObjects(['security', 'monitoring', 'common']); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const isSaml = !!process.env.VM.includes('saml') || !!process.env.VM.includes('oidc'); + + before(async () => { + await browser.setWindowSize(1200, 800); + if (process.env.SECURITY === 'YES' && !isSaml) { + await PageObjects.security.logout(); + log.debug('### log in as elastic superuser to enable monitoring'); + // Tests may be running as a non-superuser like `power` but that user + // doesn't have the cluster privs to enable monitoring. + // On the SAML config, this will fail, but the test recovers on the next + // navigate and logs in as the saml user. + } + // navigateToApp without a username and password will default to the superuser + await PageObjects.common.navigateToApp('monitoring', { insertTimestamp: false }); + }); + + it('should enable Monitoring', async () => { + await testSubjects.click('useInternalCollection'); + await testSubjects.click('enableCollectionEnabled'); + }); + + after(async () => { + if (process.env.SECURITY === 'YES' && !isSaml) { + await PageObjects.security.forceLogout(isSaml); + } + }); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/index.js new file mode 100644 index 0000000000000..f6ea0ae4aa2b5 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/monitoring/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('monitoring app - stack functional integration - index', function () { + loadTestFile(require.resolve('./_monitoring')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/_packetbeat.js b/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/_packetbeat.js new file mode 100644 index 0000000000000..e09ac478fccbd --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/_packetbeat.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const retry = getService('retry'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const appsMenu = getService('appsMenu'); + + describe('check packetbeat', function () { + before(function () { + log.debug('navigateToApp Discover'); + }); + + it('packetbeat- should have hit count GT 0', async function () { + const url = await browser.getCurrentUrl(); + log.debug(url); + if (!url.includes('kibana')) { + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + } + if (!url.includes('discover')) { + await appsMenu.clickLink('Discover'); + } + await PageObjects.discover.selectIndexPattern('packetbeat-*'); + await PageObjects.timePicker.setCommonlyUsedTime('Today'); + await retry.try(async function () { + const hitCount = parseInt(await PageObjects.discover.getHitCount()); + expect(hitCount).to.be.greaterThan(0); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/index.js new file mode 100644 index 0000000000000..5bb4582eb16de --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/packetbeat/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('packetbeat app', function () { + loadTestFile(require.resolve('./_packetbeat')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/reporting/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/index.js new file mode 100644 index 0000000000000..98771a57693a2 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/index.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService, loadTestFile }) { + describe('reporting app', function () { + const browser = getService('browser'); + + before(async () => { + await browser.setWindowSize(1200, 800); + }); + + loadTestFile(require.resolve('./reporting_watcher_png')); + loadTestFile(require.resolve('./reporting_watcher')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher.js b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher.js new file mode 100644 index 0000000000000..c373c797bef50 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getWatcher, deleteWatcher, putWatcher } from './util'; + +export default function ({ getService, getPageObjects }) { + describe('watcher app', function describeIndexTests() { + const config = getService('config'); + const servers = config.get('servers'); + const retry = getService('retry'); + const log = getService('log'); + const client = getService('es'); + + const KIBANAIP = process.env.KIBANAIP; + const VERSION_NUMBER = process.env.VERSION_NUMBER; + const VM = process.env.VM; + const VERSION_BUILD_HASH = process.env.VERSION_BUILD_HASH; + const STARTEDBY = process.env.STARTEDBY; + const REPORTING_TEST_EMAILS = process.env.REPORTING_TEST_EMAILS; + + const PageObjects = getPageObjects(['common']); + describe('PDF Reporting watch', function () { + let id = 'watcher_report-'; + id = id + new Date().getTime(); // For debugging. + const watch = { id }; + const interval = 10; + const emails = REPORTING_TEST_EMAILS.split(','); + + // https://localhost:5601/api/reporting/generate/printablePdf?jobParams=(objectType:dashboard,queryString:%27_g%3D(refreshInterval%3A(display%3AOff%2Cpause%3A!!f%2Cvalue%3A0)%2Ctime%3A(from%3Anow-7d%2Cmode%3Aquick%2Cto%3Anow))%26_a%3D(description%3A%2527%2527%2Cfilters%3A!!()%2CfullScreenMode%3A!!f%2Coptions%3A(darkTheme%3A!!f)%2Cpanels%3A!!((col%3A1%2Cid%3ASystem-Navigation%2CpanelIndex%3A9%2Crow%3A1%2Csize_x%3A8%2Csize_y%3A1%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3Ac6f2ffd0-4d17-11e7-a196-69b9a7a020a9%2CpanelIndex%3A11%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A7%2Cid%3Afe064790-1b1f-11e7-bec4-a5e9ec5cab8b%2CpanelIndex%3A12%2Crow%3A4%2Csize_x%3A6%2Csize_y%3A5%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3A%2527855899e0-1b1c-11e7-b09e-037021c4f8df%2527%2CpanelIndex%3A13%2Crow%3A4%2Csize_x%3A6%2Csize_y%3A5%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3A%25277cdb1330-4d1a-11e7-a196-69b9a7a020a9%2527%2CpanelIndex%3A14%2Crow%3A9%2Csize_x%3A12%2Csize_y%3A6%2Ctype%3Avisualization)%2C(col%3A9%2Cid%3A%2527522ee670-1b92-11e7-bec4-a5e9ec5cab8b%2527%2CpanelIndex%3A16%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A11%2Cid%3A%25271aae9140-1b93-11e7-8ada-3df93aab833e%2527%2CpanelIndex%3A17%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A7%2Cid%3A%2527825fdb80-4d1d-11e7-b5f2-2b7c1895bf32%2527%2CpanelIndex%3A18%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A5%2Cid%3Ad3166e80-1b91-11e7-bec4-a5e9ec5cab8b%2CpanelIndex%3A19%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A3%2Cid%3A%252783e12df0-1b91-11e7-bec4-a5e9ec5cab8b%2527%2CpanelIndex%3A20%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A9%2Cid%3Ae9d22060-4d64-11e7-aa29-87a97a796de6%2CpanelIndex%3A21%2Crow%3A1%2Csize_x%3A4%2Csize_y%3A1%2Ctype%3Avisualization))%2Cquery%3A(language%3Alucene%2Cquery%3A(query_string%3A(analyze_wildcard%3A!!t%2Cquery%3A%2527*%2527)))%2CtimeRestore%3A!!f%2Ctitle%3A%2527Metricbeat%2Bsystem%2Boverview%2527%2CuiState%3A(P-11%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-12%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-14%3A(vis%3A(defaultColors%3A(%25270%2525%2B-%2B8.75%2525%2527%3A%2527rgb(247%2C252%2C245)%2527%2C%252717.5%2525%2B-%2B26.25%2525%2527%3A%2527rgb(116%2C196%2C118)%2527%2C%252726.25%2525%2B-%2B35%2525%2527%3A%2527rgb(35%2C139%2C69)%2527%2C%25278.75%2525%2B-%2B17.5%2525%2527%3A%2527rgb(199%2C233%2C192)%2527)))%2CP-16%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-2%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-3%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527))))%2CviewMode%3Aview)%27,savedObjectId:Metricbeat-system-overview) + // https://localhost:5601/api/reporting/generate/printablePdf?jobParams=(objectType:dashboard,queryString:%27_g%3D()%26_a%3D(description%3A%2527%2527%2Cfilters%3A!!()%2CfullScreenMode%3A!!f%2Coptions%3A(darkTheme%3A!!f)%2Cpanels%3A!!((col%3A1%2Cid%3ASystem-Navigation%2CpanelIndex%3A9%2Crow%3A1%2Csize_x%3A12%2Csize_y%3A1%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3Ac6f2ffd0-4d17-11e7-a196-69b9a7a020a9%2CpanelIndex%3A11%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A7%2Cid%3Afe064790-1b1f-11e7-bec4-a5e9ec5cab8b%2CpanelIndex%3A12%2Crow%3A4%2Csize_x%3A6%2Csize_y%3A5%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3A%2527855899e0-1b1c-11e7-b09e-037021c4f8df%2527%2CpanelIndex%3A13%2Crow%3A4%2Csize_x%3A6%2Csize_y%3A5%2Ctype%3Avisualization)%2C(col%3A1%2Cid%3A%25277cdb1330-4d1a-11e7-a196-69b9a7a020a9%2527%2CpanelIndex%3A14%2Crow%3A9%2Csize_x%3A12%2Csize_y%3A6%2Ctype%3Avisualization)%2C(col%3A9%2Cid%3A%2527522ee670-1b92-11e7-bec4-a5e9ec5cab8b%2527%2CpanelIndex%3A16%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A11%2Cid%3A%25271aae9140-1b93-11e7-8ada-3df93aab833e%2527%2CpanelIndex%3A17%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A7%2Cid%3A%2527825fdb80-4d1d-11e7-b5f2-2b7c1895bf32%2527%2CpanelIndex%3A18%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A5%2Cid%3Ad3166e80-1b91-11e7-bec4-a5e9ec5cab8b%2CpanelIndex%3A19%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization)%2C(col%3A3%2Cid%3A%252783e12df0-1b91-11e7-bec4-a5e9ec5cab8b%2527%2CpanelIndex%3A20%2Crow%3A2%2Csize_x%3A2%2Csize_y%3A2%2Ctype%3Avisualization))%2Cquery%3A(language%3Alucene%2Cquery%3A(query_string%3A(analyze_wildcard%3A!!t%2Cdefault_field%3A%2527*%2527%2Cquery%3A%2527*%2527)))%2CtimeRestore%3A!!f%2Ctitle%3A%2527%255BMetricbeat%2BSystem%255D%2BOverview%2527%2CuiState%3A(P-11%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-12%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-14%3A(vis%3A(defaultColors%3A(%25270%2525%2B-%2B8.75%2525%2527%3A%2527rgb(247%2C252%2C245)%2527%2C%252717.5%2525%2B-%2B26.25%2525%2527%3A%2527rgb(116%2C196%2C118)%2527%2C%252726.25%2525%2B-%2B35%2525%2527%3A%2527rgb(35%2C139%2C69)%2527%2C%25278.75%2525%2B-%2B17.5%2525%2527%3A%2527rgb(199%2C233%2C192)%2527)))%2CP-16%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-2%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527)))%2CP-3%3A(vis%3A(defaultColors%3A(%25270%2B-%2B100%2527%3A%2527rgb(0%2C104%2C55)%2527))))%2CviewMode%3Aview)%27,savedObjectId:Metricbeat-system-overview) + // https://localhost:5601 + // "/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FChicago,layout:(dimensions:(height:540.5,width:633),id:preserve_layout),objectType:visualization,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fvisualize%2Fedit%2FLatency-histogram%3F_g%3D(refreshInterval:(pause:!!t,value:0),time:(from:now-24h,mode:quick,to:now))%26_a%3D(filters:!!(),linked:!!t,query:(language:lucene,query:!%27!%27),uiState:(),vis:(aggs:!!((enabled:!!t,id:!%271!%27,params:(),schema:metric,type:count),(enabled:!!t,id:!%272!%27,params:(extended_bounds:(),field:responsetime,interval:10),schema:segment,type:histogram)),params:(addLegend:!!t,addTimeMarker:!!f,addTooltip:!!t,categoryAxes:!!((id:CategoryAxis-1,labels:(show:!!t,truncate:100),position:bottom,scale:(type:linear),show:!!t,style:(),title:(),type:category)),defaultYExtents:!!f,grid:(categoryLines:!!f,style:(color:%2523eee)),interpolate:linear,legendPosition:right,mode:stacked,scale:linear,seriesParams:!!((data:(id:!%271!%27,label:Count),interpolate:cardinal,mode:stacked,show:true,type:area,valueAxis:ValueAxis-1)),setYExtents:!!f,shareYAxis:!!t,smoothLines:!!t,times:!!(),type:area,valueAxes:!!((id:ValueAxis-1,labels:(filter:!!f,rotate:0,show:!!t,truncate:100),name:LeftAxis-1,position:left,scale:(defaultYExtents:!!f,mode:normal,setYExtents:!!f,type:linear),show:!!t,style:(),title:(text:Count),type:value)),yAxis:()),title:!%27Latency%2Bhistogram!%27,type:area))%27),title:%27Latency%20histogram%27) + const url = + servers.kibana.protocol + + '://' + + KIBANAIP + + ':' + + servers.kibana.port + + '/api/reporting/generate/printablePdf?jobParams=(browserTimezone:America%2FChicago,layout:(dimensions:(height:2024,width:3006.400146484375),id:preserve_layout),objectType:dashboard,relativeUrls:!(%27%2Fapp%2Fkibana%23%2Fdashboard%2F722b74f0-b882-11e8-a6d9-e546fe2bba5f%3F_g%3D(refreshInterval:(pause:!!f,value:900000),time:(from:now-7d,to:now))%26_a%3D(description:!%27Analyze%2Bmock%2BeCommerce%2Borders%2Band%2Brevenue!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(vis:(colors:(!%27Men!!!%27s%2BAccessories!%27:%252382B5D8,!%27Men!!!%27s%2BClothing!%27:%2523F9BA8F,!%27Men!!!%27s%2BShoes!%27:%2523F29191,!%27Women!!!%27s%2BAccessories!%27:%2523F4D598,!%27Women!!!%27s%2BClothing!%27:%252370DBED,!%27Women!!!%27s%2BShoes!%27:%2523B7DBAB))),gridData:(h:10,i:!%271!%27,w:36,x:12,y:18),id:!%2737cc8650-b882-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%271!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(FEMALE:%25236ED0E0,MALE:%2523447EBC),legendOpen:!!f)),gridData:(h:11,i:!%272!%27,w:12,x:12,y:7),id:ed8436b0-b88b-11e8-a6d9-e546fe2bba5f,panelIndex:!%272!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:7,i:!%273!%27,w:18,x:0,y:0),id:!%2709ffee60-b88c-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%273!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:7,i:!%274!%27,w:30,x:18,y:0),id:!%271c389590-b88d-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%274!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:11,i:!%275!%27,w:48,x:0,y:28),id:!%2745e07720-b890-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%275!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:10,i:!%276!%27,w:12,x:0,y:18),id:!%2710f1a240-b891-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%276!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:11,i:!%277!%27,w:12,x:0,y:7),id:b80e6540-b891-11e8-a6d9-e546fe2bba5f,panelIndex:!%277!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(!%270%2B-%2B50!%27:%2523E24D42,!%2750%2B-%2B75!%27:%2523EAB839,!%2775%2B-%2B100!%27:%25237EB26D),defaultColors:(!%270%2B-%2B50!%27:!%27rgb(165,0,38)!%27,!%2750%2B-%2B75!%27:!%27rgb(255,255,190)!%27,!%2775%2B-%2B100!%27:!%27rgb(0,104,55)!%27),legendOpen:!!f)),gridData:(h:11,i:!%278!%27,w:12,x:24,y:7),id:!%274b3ec120-b892-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%278!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(!%270%2B-%2B2!%27:%2523E24D42,!%272%2B-%2B3!%27:%2523F2C96D,!%273%2B-%2B4!%27:%25239AC48A),defaultColors:(!%270%2B-%2B2!%27:!%27rgb(165,0,38)!%27,!%272%2B-%2B3!%27:!%27rgb(255,255,190)!%27,!%273%2B-%2B4!%27:!%27rgb(0,104,55)!%27),legendOpen:!!f)),gridData:(h:11,i:!%279!%27,w:12,x:36,y:7),id:!%279ca7aa90-b892-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%279!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:18,i:!%2710!%27,w:48,x:0,y:54),id:!%273ba638e0-b894-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%2710!%27,type:search,version:!%277.0.0-alpha1!%27),(embeddableConfig:(isLayerTOCOpen:!!f,mapCenter:(lat:45.88578,lon:-15.07605,zoom:2.11),openTOCDetails:!!()),gridData:(h:15,i:!%2711!%27,w:24,x:0,y:39),id:!%272c9c1f60-1909-11e9-919b-ffe5949a18d2!%27,panelIndex:!%2711!%27,type:map,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:15,i:!%2712!%27,w:24,x:24,y:39),id:b72dd430-bb4d-11e8-9c84-77068524bcab,panelIndex:!%2712!%27,type:visualization,version:!%277.0.0-alpha1!%27)),query:(language:kuery,query:!%27!%27),timeRestore:!!t,title:!%27%255BeCommerce%255D%2BRevenue%2BDashboard!%27,viewMode:view)%27),title:%27%5BeCommerce%5D%20Revenue%20Dashboard%27)'; + + const body = { + trigger: { + schedule: { + interval: `${interval}s`, + }, + }, + actions: { + email_admin: { + email: { + to: emails, + subject: + 'PDF ' + + VERSION_NUMBER + + ' ' + + id + + ', VM=' + + VM + + ' ' + + VERSION_BUILD_HASH + + ' by:' + + STARTEDBY, + attachments: { + 'test_report.pdf': { + reporting: { + url: url, + auth: { + basic: { + username: servers.elasticsearch.username, + password: servers.elasticsearch.password, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + it('should successfully add a new watch for PDF Reporting', async () => { + await putWatcher(watch, id, body, client, log); + }); + it('should be successful and increment revision', async () => { + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + }); + it('should delete watch and update revision', async () => { + await deleteWatcher(watch, id, client, log); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher_png.js b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher_png.js new file mode 100644 index 0000000000000..ac247cc23900d --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/reporting_watcher_png.js @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getWatcher, deleteWatcher, putWatcher } from './util'; + +export default ({ getService, getPageObjects }) => { + describe('watcher app', () => { + const config = getService('config'); + const servers = config.get('servers'); + const retry = getService('retry'); + const log = getService('log'); + const client = getService('es'); + + const KIBANAIP = process.env.KIBANAIP; + const VERSION_NUMBER = process.env.VERSION_NUMBER; + const VM = process.env.VM; + const VERSION_BUILD_HASH = process.env.VERSION_BUILD_HASH; + const STARTEDBY = process.env.STARTEDBY; + const REPORTING_TEST_EMAILS = process.env.REPORTING_TEST_EMAILS; + + const PageObjects = getPageObjects(['common']); + describe('PNG Reporting watch', () => { + let id = 'watcher_png_report-'; + id = id + new Date().getTime(); // For debugging. + const watch = { id }; + const reportingUrl = + servers.kibana.protocol + + '://' + + KIBANAIP + + ':' + + servers.kibana.port + + '/api/reporting/generate/png?jobParams=(browserTimezone:America%2FChicago,layout:(dimensions:(height:2024,width:3006.400146484375),id:png),objectType:dashboard,relativeUrl:%27%2Fapp%2Fkibana%23%2Fdashboard%2F722b74f0-b882-11e8-a6d9-e546fe2bba5f%3F_g%3D(refreshInterval:(pause:!!f,value:900000),time:(from:now-7d,to:now))%26_a%3D(description:!%27Analyze%2Bmock%2BeCommerce%2Borders%2Band%2Brevenue!%27,filters:!!(),fullScreenMode:!!f,options:(hidePanelTitles:!!f,useMargins:!!t),panels:!!((embeddableConfig:(vis:(colors:(!%27Men!!!%27s%2BAccessories!%27:%252382B5D8,!%27Men!!!%27s%2BClothing!%27:%2523F9BA8F,!%27Men!!!%27s%2BShoes!%27:%2523F29191,!%27Women!!!%27s%2BAccessories!%27:%2523F4D598,!%27Women!!!%27s%2BClothing!%27:%252370DBED,!%27Women!!!%27s%2BShoes!%27:%2523B7DBAB))),gridData:(h:10,i:!%271!%27,w:36,x:12,y:18),id:!%2737cc8650-b882-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%271!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(FEMALE:%25236ED0E0,MALE:%2523447EBC),legendOpen:!!f)),gridData:(h:11,i:!%272!%27,w:12,x:12,y:7),id:ed8436b0-b88b-11e8-a6d9-e546fe2bba5f,panelIndex:!%272!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:7,i:!%273!%27,w:18,x:0,y:0),id:!%2709ffee60-b88c-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%273!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:7,i:!%274!%27,w:30,x:18,y:0),id:!%271c389590-b88d-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%274!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:11,i:!%275!%27,w:48,x:0,y:28),id:!%2745e07720-b890-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%275!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:10,i:!%276!%27,w:12,x:0,y:18),id:!%2710f1a240-b891-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%276!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:11,i:!%277!%27,w:12,x:0,y:7),id:b80e6540-b891-11e8-a6d9-e546fe2bba5f,panelIndex:!%277!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(!%270%2B-%2B50!%27:%2523E24D42,!%2750%2B-%2B75!%27:%2523EAB839,!%2775%2B-%2B100!%27:%25237EB26D),defaultColors:(!%270%2B-%2B50!%27:!%27rgb(165,0,38)!%27,!%2750%2B-%2B75!%27:!%27rgb(255,255,190)!%27,!%2775%2B-%2B100!%27:!%27rgb(0,104,55)!%27),legendOpen:!!f)),gridData:(h:11,i:!%278!%27,w:12,x:24,y:7),id:!%274b3ec120-b892-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%278!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(vis:(colors:(!%270%2B-%2B2!%27:%2523E24D42,!%272%2B-%2B3!%27:%2523F2C96D,!%273%2B-%2B4!%27:%25239AC48A),defaultColors:(!%270%2B-%2B2!%27:!%27rgb(165,0,38)!%27,!%272%2B-%2B3!%27:!%27rgb(255,255,190)!%27,!%273%2B-%2B4!%27:!%27rgb(0,104,55)!%27),legendOpen:!!f)),gridData:(h:11,i:!%279!%27,w:12,x:36,y:7),id:!%279ca7aa90-b892-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%279!%27,type:visualization,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:18,i:!%2710!%27,w:48,x:0,y:54),id:!%273ba638e0-b894-11e8-a6d9-e546fe2bba5f!%27,panelIndex:!%2710!%27,type:search,version:!%277.0.0-alpha1!%27),(embeddableConfig:(isLayerTOCOpen:!!f,mapCenter:(lat:45.88578,lon:-15.07605,zoom:2.11),openTOCDetails:!!()),gridData:(h:15,i:!%2711!%27,w:24,x:0,y:39),id:!%272c9c1f60-1909-11e9-919b-ffe5949a18d2!%27,panelIndex:!%2711!%27,type:map,version:!%277.0.0-alpha1!%27),(embeddableConfig:(),gridData:(h:15,i:!%2712!%27,w:24,x:24,y:39),id:b72dd430-bb4d-11e8-9c84-77068524bcab,panelIndex:!%2712!%27,type:visualization,version:!%277.0.0-alpha1!%27)),query:(language:kuery,query:!%27!%27),timeRestore:!!t,title:!%27%255BeCommerce%255D%2BRevenue%2BDashboard!%27,viewMode:view)%27,title:%27%5BeCommerce%5D%20Revenue%20Dashboard%27)'; + const emails = REPORTING_TEST_EMAILS.split(','); + const interval = 10; + const body = { + trigger: { + schedule: { + interval: `${interval}s`, + }, + }, + actions: { + email_admin: { + email: { + to: emails, + subject: + 'PNG ' + + VERSION_NUMBER + + ' ' + + id + + ', VM=' + + VM + + ' ' + + VERSION_BUILD_HASH + + ' by:' + + STARTEDBY, + attachments: { + 'test_report.png': { + reporting: { + url: reportingUrl, + auth: { + basic: { + username: servers.elasticsearch.username, + password: servers.elasticsearch.password, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + it('should successfully add a new watch for PNG Reporting', async () => { + await putWatcher(watch, id, body, client, log); + }); + it('should be successful and increment revision', async () => { + await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime); + }); + it('should delete watch and update revision', async () => { + await deleteWatcher(watch, id, client, log); + }); + }); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/reporting/util.js b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/util.js new file mode 100644 index 0000000000000..3c959656a3c57 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/reporting/util.js @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export const pretty = (x) => JSON.stringify(x, null, 2); +export const buildUrl = ({ protocol, auth, hostname, port }) => + new URL(`${protocol}://${auth}@${hostname}:${port}`); +export const putWatcher = async (watch, id, body, client, log) => { + const putWatchResponse = await client.watcher.putWatch({ ...watch, body }); + log.debug(`# putWatchResponse \n${pretty(putWatchResponse)}`); + expect(putWatchResponse.body._id).to.eql(id); + expect(putWatchResponse.statusCode).to.eql('201'); + expect(putWatchResponse.body._version).to.eql('1'); +}; +export const getWatcher = async (watch, id, client, log, common, tryForTime) => { + await common.sleep(50000); + await tryForTime( + 250000, + async () => { + await common.sleep(25000); + + await watcherHistory(id, client, log); + + const getWatchResponse = await client.watcher.getWatch(watch); + log.debug(`\n getWatchResponse: ${JSON.stringify(getWatchResponse)}`); + expect(getWatchResponse.body._id).to.eql(id); + expect(getWatchResponse.body._version).to.be.above(1); + log.debug(`\n getWatchResponse.body._version: ${getWatchResponse.body._version}`); + expect(getWatchResponse.body.status.execution_state).to.eql('executed'); + expect(getWatchResponse.body.status.actions.email_admin.last_execution.successful).to.eql( + true + ); + + return getWatchResponse; + }, + async function onFailure(obj) { + log.debug(`\n### tryForTime-Failure--raw body: \n\t${pretty(obj)}`); + } + ); +}; +export const deleteWatcher = async (watch, id, client, log) => { + const deleteResponse = await client.watcher.deleteWatch(watch); + log.debug('\nDelete Response=' + pretty(deleteResponse) + '\n'); + expect(deleteResponse.body._id).to.eql(id); + expect(deleteResponse.body.found).to.eql(true); + expect(deleteResponse.statusCode).to.eql('200'); +}; +async function watcherHistory(watchId, client, log) { + const { body } = await client.search({ + index: '.watcher-history*', + body: { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + watchId, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + }); + log.debug(`\nwatcherHistoryResponse \n${pretty(body)}\n`); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/e_commerce.js b/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/e_commerce.js new file mode 100644 index 0000000000000..306f30133f6ee --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/e_commerce.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService, getPageObjects }) { + describe('eCommerce Sample Data', function sampleData() { + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'home']); + const testSubjects = getService('testSubjects'); + + before(async () => { + await browser.setWindowSize(1200, 800); + await PageObjects.common.navigateToUrl('home', '/home/tutorial_directory/sampleData', { + useActualUrl: true, + insertTimestamp: false, + }); + await PageObjects.common.sleep(3000); + }); + + it('install eCommerce sample data', async function installECommerceData() { + await PageObjects.home.addSampleDataSet('ecommerce'); + await PageObjects.common.sleep(5000); + // verify it's installed by finding the remove link + await testSubjects.find('removeSampleDataSetecommerce'); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/index.js new file mode 100644 index 0000000000000..4b9178c753b9a --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/sample_data/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default ({ loadTestFile }) => { + describe('sample data', function () { + loadTestFile(require.resolve('./e_commerce')); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/_telemetry.js b/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/_telemetry.js new file mode 100644 index 0000000000000..09698675f0678 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/_telemetry.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default ({ getService, getPageObjects }) => { + const log = getService('log'); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + const PageObjects = getPageObjects(['common', 'monitoring', 'header']); + + describe('telemetry', function () { + before(async () => { + log.debug('monitoring'); + await browser.setWindowSize(1200, 800); + await appsMenu.clickLink('Stack Monitoring'); + }); + + it('should show banner Help us improve Kibana and Elasticsearch', async () => { + const expectedMessage = `Help us improve the Elastic Stack +To learn about how usage data helps us manage and improve our products and services, see our Privacy Statement. To stop collection, disable usage data here. +Dismiss`; + const actualMessage = await PageObjects.monitoring.getWelcome(); + log.debug(`X-Pack message = ${actualMessage}`); + expect(actualMessage).to.be(expectedMessage); + }); + }); +}; diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/index.js new file mode 100644 index 0000000000000..0803f48ed90fe --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/telemetry/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('telemetry feature', function () { + this.tags('ciGroup1'); + loadTestFile(require.resolve('./_telemetry')); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/_winlogbeat.js b/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/_winlogbeat.js new file mode 100644 index 0000000000000..657fdf4daaeb4 --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/_winlogbeat.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const retry = getService('retry'); + const appsMenu = getService('appsMenu'); + + describe('check winlogbeat', function () { + it('winlogbeat- should have hit count GT 0', async function () { + const url = await browser.getCurrentUrl(); + log.debug(url); + if (!url.includes('kibana')) { + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + } else if (!url.includes('discover')) { + await appsMenu.clickLink('Discover'); + } + await PageObjects.discover.selectIndexPattern('winlogbeat-*'); + await PageObjects.timePicker.setCommonlyUsedTime('Today'); + await retry.try(async function () { + const hitCount = parseInt(await PageObjects.discover.getHitCount()); + expect(hitCount).to.be.greaterThan(0); + }); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/index.js b/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/index.js new file mode 100644 index 0000000000000..a940be781ccfe --- /dev/null +++ b/x-pack/test/stack_functional_integration/test/functional/apps/winlogbeat/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('winlogbeat app', function () { + loadTestFile(require.resolve('./_winlogbeat')); + }); +} From 90fb7a6c2d16d0d2a007855b1032a526c029260e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 8 Jul 2020 11:06:49 -0700 Subject: [PATCH 09/10] [Ingest Manager] Show experimental packages by default (#70997) * Add beta and experimental badges to epm list and detail pages; clean up some epm components * Clean up styled warnings * Fix types * Allow experimental query param to be passed through to registry /search * Allow experimental query param to be passed through to registry /categories endpoint * Fix buggy categories count (#64981) * Always enable experimental packages and categories * Handle long package names nicely; misc layout tweaks * Move experimental=true flag to client side * Prevent layout jumps even more * Adjust beta/experimental badge tooltip copy --- .../ingest_manager/common/types/models/epm.ts | 4 + .../common/types/rest_spec/epm.ts | 8 + .../hooks/use_package_icon_type.ts | 2 +- .../ingest_manager/hooks/use_request/epm.ts | 17 +- .../epm/components/assets_facet_group.tsx | 29 +- .../sections/epm/components/icon_panel.tsx | 64 +++-- .../sections/epm/components/icons.tsx | 23 +- .../sections/epm/components/index.ts | 5 - .../epm/components/nav_button_back.tsx | 19 -- .../sections/epm/components/package_card.tsx | 15 +- .../epm/components/package_list_grid.tsx | 22 +- .../sections/epm/components/release_badge.ts | 25 ++ .../sections/epm/screens/detail/content.tsx | 34 +-- .../sections/epm/screens/detail/header.tsx | 89 ------ .../sections/epm/screens/detail/index.tsx | 258 ++++++++++++++---- .../sections/epm/screens/detail/layout.tsx | 2 +- .../epm/screens/detail/screenshots.tsx | 63 +++-- .../epm/screens/detail/settings_panel.tsx | 2 +- .../epm/screens/detail/side_nav_links.tsx | 17 +- .../sections/epm/screens/home/header.tsx | 30 +- .../sections/epm/screens/home/index.tsx | 15 +- .../epm/screens/home/search_packages.tsx | 33 --- .../epm/screens/home/search_results.tsx | 33 --- .../ingest_manager/types/index.ts | 3 + .../server/routes/epm/handlers.ts | 10 +- .../ingest_manager/server/routes/epm/index.ts | 3 +- .../server/services/epm/packages/get.ts | 10 +- .../server/services/epm/registry/index.ts | 34 ++- .../server/types/rest_spec/epm.ts | 7 + 29 files changed, 485 insertions(+), 391 deletions(-) delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/release_badge.ts delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_packages.tsx delete mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index 23e31227cbf3c..a34038d4fba04 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -42,6 +42,8 @@ export enum AgentAssetType { input = 'input', } +export type RegistryRelease = 'ga' | 'beta' | 'experimental'; + // from /package/{name} // type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go // https://github.com/elastic/package-registry/blob/master/docs/api/package.json @@ -49,6 +51,7 @@ export interface RegistryPackage { name: string; title?: string; version: string; + release?: RegistryRelease; readme?: string; description: string; type: string; @@ -114,6 +117,7 @@ export type RegistrySearchResult = Pick< | 'name' | 'title' | 'version' + | 'release' | 'description' | 'type' | 'icons' diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts index c5035d2d44432..1901b8c0c7039 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts @@ -12,13 +12,21 @@ import { PackageInfo, } from '../models/epm'; +export interface GetCategoriesRequest { + query: { + experimental?: boolean; + }; +} + export interface GetCategoriesResponse { response: CategorySummaryList; success: boolean; } + export interface GetPackagesRequest { query: { category?: string; + experimental?: boolean; }; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts index 011e0c69f2683..e5a7191372e9c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import { ICON_TYPES } from '@elastic/eui'; -import { PackageInfo, PackageListItem } from '../../../../common/types/models'; +import { PackageInfo, PackageListItem } from '../types'; import { useLinks } from '../sections/epm/hooks'; import { sendGetPackageInfoByKey } from './index'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts index 64bee1763b08b..40a22f6b44d50 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpFetchQuery } from 'src/core/public'; import { useRequest, sendRequest } from './use_request'; import { epmRouteService } from '../../services'; import { + GetCategoriesRequest, GetCategoriesResponse, + GetPackagesRequest, GetPackagesResponse, GetLimitedPackagesResponse, GetInfoResponse, @@ -16,18 +17,19 @@ import { DeletePackageResponse, } from '../../types'; -export const useGetCategories = () => { +export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => { return useRequest({ path: epmRouteService.getCategoriesPath(), method: 'get', + query: { experimental: true, ...query }, }); }; -export const useGetPackages = (query: HttpFetchQuery = {}) => { +export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => { return useRequest({ path: epmRouteService.getListPath(), method: 'get', - query, + query: { experimental: true, ...query }, }); }; @@ -52,6 +54,13 @@ export const sendGetPackageInfoByKey = (pkgkey: string) => { }); }; +export const useGetFileByPath = (filePath: string) => { + return useRequest({ + path: epmRouteService.getFilePath(filePath), + method: 'get', + }); +}; + export const sendGetFileByPath = (filePath: string) => { return sendRequest({ path: epmRouteService.getFilePath(filePath), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx index ac74b09ab4391..24b4baeaa092b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx @@ -30,19 +30,24 @@ import { ServiceTitleMap, } from '../constants'; -export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByType }) { - const FirstHeaderRow = styled(EuiFlexGroup)` - padding: 0 0 ${(props) => props.theme.eui.paddingSizes.m} 0; - `; +const FirstHeaderRow = styled(EuiFlexGroup)` + padding: 0 0 ${(props) => props.theme.eui.paddingSizes.m} 0; +`; + +const HeaderRow = styled(EuiFlexGroup)` + padding: ${(props) => props.theme.eui.paddingSizes.m} 0; +`; - const HeaderRow = styled(EuiFlexGroup)` - padding: ${(props) => props.theme.eui.paddingSizes.m} 0; - `; +const FacetGroup = styled(EuiFacetGroup)` + flex-grow: 0; +`; - const FacetGroup = styled(EuiFacetGroup)` - flex-grow: 0; - `; +const FacetButton = styled(EuiFacetButton)` + padding: '${(props) => props.theme.eui.paddingSizes.xs} 0'; + height: 'unset'; +`; +export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByType }) { return ( {entries(assets).map(([service, typeToParts], index) => { @@ -77,10 +82,6 @@ export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByT // only kibana assets have icons const iconType = type in AssetIcons && AssetIcons[type]; const iconNode = iconType ? : ''; - const FacetButton = styled(EuiFacetButton)` - padding: '${(props) => props.theme.eui.paddingSizes.xs} 0'; - height: 'unset'; - `; return ( + parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.spacerSizes.xl) * 2}px; + height: 1px; +`; + +const Panel = styled(EuiPanel)` + padding: ${(props) => props.theme.eui.spacerSizes.xl}; + margin-bottom: -100%; + svg, + img { + height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + } + .euiFlexItem { + height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + justify-content: center; + } +`; + +export function IconPanel({ + packageName, + version, + icons, +}: Pick) { + const iconType = usePackageIconType({ packageName, version, icons }); -export function IconPanel({ iconType }: { iconType: IconType }) { - const Panel = styled(EuiPanel)` - /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ - &&& { - position: absolute; - text-align: center; - vertical-align: middle; - padding: ${(props) => props.theme.eui.spacerSizes.xl}; - svg, - img { - height: ${(props) => props.theme.eui.euiKeyPadMenuSize}; - width: ${(props) => props.theme.eui.euiKeyPadMenuSize}; - } - } - `; + return ( + + + + + + ); +} +export function LoadingIconPanel() { return ( - - - + + + + + ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx index acdcd5b9a3406..3f0803af6daae 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx @@ -3,13 +3,20 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon } from '@elastic/eui'; import React from 'react'; -import styled from 'styled-components'; +import { EuiIconTip, EuiIconProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; -export const StyledAlert = styled(EuiIcon)` - color: ${(props) => props.theme.eui.euiColorWarning}; - padding: 0 5px; -`; - -export const UpdateIcon = () => ; +export const UpdateIcon = ({ size = 'm' }: { size?: EuiIconProps['size'] }) => ( + +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts deleted file mode 100644 index 41bc2aa258807..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts +++ /dev/null @@ -1,5 +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; - * you may not use this file except in compliance with the Elastic License. - */ diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx deleted file mode 100644 index 3fcf9758368de..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx +++ /dev/null @@ -1,19 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiButtonEmpty } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -export function NavButtonBack({ href, text }: { href: string; text: string }) { - const ButtonEmpty = styled(EuiButtonEmpty)` - margin-right: ${(props) => props.theme.eui.spacerSizes.xl}; - `; - return ( - - {text} - - ); -} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index e3d8cdc8f4985..cf98f9dc90230 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -9,12 +9,9 @@ import { EuiCard } from '@elastic/eui'; import { PackageInfo, PackageListItem } from '../../../types'; import { useLink } from '../../../hooks'; import { PackageIcon } from '../../../components/package_icon'; +import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from './release_badge'; -export interface BadgeProps { - showInstalledBadge?: boolean; -} - -type PackageCardProps = (PackageListItem | PackageInfo) & BadgeProps; +type PackageCardProps = PackageListItem | PackageInfo; // adding the `href` causes EuiCard to use a `a` instead of a `button` // `a` tags use `euiLinkColor` which results in blueish Badge text @@ -27,7 +24,7 @@ export function PackageCard({ name, title, version, - showInstalledBadge, + release, status, icons, ...restProps @@ -41,12 +38,14 @@ export function PackageCard({ return ( } href={getHref('integration_details', { pkgkey: `${name}-${urlVersion}` })} + betaBadgeLabel={release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined} + betaBadgeTooltipContent={ + release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined + } /> ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx index dbf454acd2b74..0c1199f7c8867 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx @@ -20,22 +20,16 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { Loading } from '../../../components'; import { PackageList } from '../../../types'; import { useLocalSearch, searchIdField } from '../hooks'; -import { BadgeProps, PackageCard } from './package_card'; +import { PackageCard } from './package_card'; -type ListProps = { +interface ListProps { isLoading?: boolean; controls?: ReactNode; title: string; list: PackageList; -} & BadgeProps; +} -export function PackageListGrid({ - isLoading, - controls, - title, - list, - showInstalledBadge, -}: ListProps) { +export function PackageListGrid({ isLoading, controls, title, list }: ListProps) { const initialQuery = EuiSearchBar.Query.MATCH_ALL; const [query, setQuery] = useState(initialQuery); @@ -71,7 +65,7 @@ export function PackageListGrid({ .includes(item[searchIdField]) ) : list; - gridContent = ; + gridContent = ; } return ( @@ -108,16 +102,16 @@ function ControlsColumn({ controls, title }: ControlsColumnProps) { - {controls} + {controls} ); } -type GridColumnProps = { +interface GridColumnProps { list: PackageList; -} & BadgeProps; +} function GridColumn({ list }: GridColumnProps) { return ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/release_badge.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/release_badge.ts new file mode 100644 index 0000000000000..f3520b4e7a9b3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/release_badge.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { RegistryRelease } from '../../../types'; + +export const RELEASE_BADGE_LABEL: { [key in Exclude]: string } = { + beta: i18n.translate('xpack.ingestManager.epm.releaseBadge.betaLabel', { + defaultMessage: 'Beta', + }), + experimental: i18n.translate('xpack.ingestManager.epm.releaseBadge.experimentalLabel', { + defaultMessage: 'Experimental', + }), +}; + +export const RELEASE_BADGE_DESCRIPTION: { [key in Exclude]: string } = { + beta: i18n.translate('xpack.ingestManager.epm.releaseBadge.betaDescription', { + defaultMessage: 'This integration is not recommended for use in production environments.', + }), + experimental: i18n.translate('xpack.ingestManager.epm.releaseBadge.experimentalDescription', { + defaultMessage: 'This integration may have breaking changes or be removed in a future release.', + }), +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx index c9a8cabdf414b..f53b4e9150ca1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx @@ -16,22 +16,22 @@ import { SideNavLinks } from './side_nav_links'; import { PackageConfigsPanel } from './package_configs_panel'; import { SettingsPanel } from './settings_panel'; -type ContentProps = PackageInfo & Pick & { hasIconPanel: boolean }; -export function Content(props: ContentProps) { - const { hasIconPanel, name, panel, version } = props; - const SideNavColumn = hasIconPanel - ? styled(LeftColumn)` - /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ - &&& { - margin-top: 77px; - } - ` - : LeftColumn; +type ContentProps = PackageInfo & Pick; + +const SideNavColumn = styled(LeftColumn)` + /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ + &&& { + margin-top: 77px; + } +`; + +// fixes IE11 problem with nested flex items +const ContentFlexGroup = styled(EuiFlexGroup)` + flex: 0 0 auto !important; +`; - // fixes IE11 problem with nested flex items - const ContentFlexGroup = styled(EuiFlexGroup)` - flex: 0 0 auto !important; - `; +export function Content(props: ContentProps) { + const { name, panel, version } = props; return ( @@ -75,13 +75,13 @@ function RightColumnContent(props: RightColumnContentProps) { const { assets, panel } = props; switch (panel) { case 'overview': - return ( + return assets ? ( - ); + ) : null; default: return ; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx deleted file mode 100644 index 875a8f5c5c127..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx +++ /dev/null @@ -1,89 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiTitle, IconType, EuiButton } from '@elastic/eui'; -import { PackageInfo } from '../../../../types'; -import { useCapabilities, useLink } from '../../../../hooks'; -import { IconPanel } from '../../components/icon_panel'; -import { NavButtonBack } from '../../components/nav_button_back'; -import { CenterColumn, LeftColumn, RightColumn } from './layout'; -import { UpdateIcon } from '../../components/icons'; - -const FullWidthNavRow = styled(EuiPage)` - /* no left padding so link is against column left edge */ - padding-left: 0; -`; - -const Text = styled.span` - margin-right: ${(props) => props.theme.eui.euiSizeM}; -`; - -type HeaderProps = PackageInfo & { iconType?: IconType }; - -export function Header(props: HeaderProps) { - const { iconType, name, title, version, latestVersion } = props; - - let installedVersion; - if ('savedObject' in props) { - installedVersion = props.savedObject.attributes.version; - } - const hasWriteCapabilites = useCapabilities().write; - const { getHref } = useLink(); - const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false; - return ( - - - - - - {iconType ? ( - - - - ) : null} - - -

- {title} - - - {version} {updateAvailable && } - - -

-
-
- - - - - - - - - -
-
- ); -} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx index 505687068cf42..3267fbbe3733c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx @@ -3,15 +3,37 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiPage, EuiPageBody, EuiPageProps } from '@elastic/eui'; -import React, { Fragment, useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiText, + EuiSpacer, + EuiBetaBadge, + EuiButton, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; import { DetailViewPanelName, InstallStatus, PackageInfo } from '../../../../types'; -import { sendGetPackageInfoByKey, usePackageIconType, useBreadcrumbs } from '../../../../hooks'; +import { Loading, Error } from '../../../../components'; +import { + useGetPackageInfoByKey, + useBreadcrumbs, + useLink, + useCapabilities, +} from '../../../../hooks'; +import { WithHeaderLayout } from '../../../../layouts'; import { useSetPackageInstallStatus } from '../../hooks'; +import { IconPanel, LoadingIconPanel } from '../../components/icon_panel'; +import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from '../../components/release_badge'; +import { UpdateIcon } from '../../components/icons'; import { Content } from './content'; -import { Header } from './header'; export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; @@ -20,66 +42,202 @@ export interface DetailParams { panel?: DetailViewPanelName; } +const Divider = styled.div` + width: 0; + height: 100%; + border-left: ${(props) => props.theme.eui.euiBorderThin}; +`; + +// Allows child text to be truncated +const FlexItemWithMinWidth = styled(EuiFlexItem)` + min-width: 0px; +`; + +function Breadcrumbs({ packageTitle }: { packageTitle: string }) { + useBreadcrumbs('integration_details', { pkgTitle: packageTitle }); + return null; +} + export function Detail() { // TODO: fix forced cast if possible const { pkgkey, panel = DEFAULT_PANEL } = useParams() as DetailParams; + const { getHref } = useLink(); + const hasWriteCapabilites = useCapabilities().write; - const [info, setInfo] = useState(null); + // Package info state + const [packageInfo, setPackageInfo] = useState(null); const setPackageInstallStatus = useSetPackageInstallStatus(); + const updateAvailable = + packageInfo && + 'savedObject' in packageInfo && + packageInfo.savedObject && + packageInfo.savedObject.attributes.version < packageInfo.latestVersion; + + // Fetch package info + const { data: packageInfoData, error: packageInfoError, isLoading } = useGetPackageInfoByKey( + pkgkey + ); + + // Track install status state useEffect(() => { - sendGetPackageInfoByKey(pkgkey).then((response) => { - const packageInfo = response.data?.response; - const title = packageInfo?.title; - const name = packageInfo?.name; + if (packageInfoData?.response) { + const packageInfoResponse = packageInfoData.response; + setPackageInfo(packageInfoResponse); + let installedVersion; - if (packageInfo && 'savedObject' in packageInfo) { - installedVersion = packageInfo.savedObject.attributes.version; + const { name } = packageInfoData.response; + if ('savedObject' in packageInfoResponse) { + installedVersion = packageInfoResponse.savedObject.attributes.version; } - const status: InstallStatus = packageInfo?.status as any; - - // track install status state + const status: InstallStatus = packageInfoResponse?.status as any; if (name) { setPackageInstallStatus({ name, status, version: installedVersion || null }); } - if (packageInfo) { - setInfo({ ...packageInfo, title: title || '' }); - } - }); - }, [pkgkey, setPackageInstallStatus]); - - if (!info) return null; - - return ; -} + } + }, [packageInfoData, setPackageInstallStatus, setPackageInfo]); -const FullWidthHeader = styled(EuiPage)` - border-bottom: ${(props) => props.theme.eui.euiBorderThin}; - padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; -`; + const headerLeftContent = useMemo( + () => ( + + + {/* Allows button to break out of full width */} +
+ + + +
+
+ + + + {isLoading || !packageInfo ? ( + + ) : ( + + )} + + + + + + {/* Render space in place of package name while package info loads to prevent layout from jumping around */} +

{packageInfo?.title || '\u00A0'}

+
+
+ {packageInfo?.release && packageInfo.release !== 'ga' ? ( + + + + ) : null} +
+
+
+
+
+ ), + [getHref, isLoading, packageInfo] + ); -const FullWidthContent = styled(EuiPage)` - background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - padding-top: ${(props) => parseInt(props.theme.eui.paddingSizes.xl, 10) * 1.25}px; - flex-grow: 1; -`; + const headerRightContent = useMemo( + () => + packageInfo ? ( + <> + + + {[ + { + label: i18n.translate('xpack.ingestManager.epm.versionLabel', { + defaultMessage: 'Version', + }), + content: ( + + {packageInfo.version} + {updateAvailable ? ( + + + + ) : null} + + ), + }, + { isDivider: true }, + { + content: ( + + + + ), + }, + ].map((item, index) => ( + + {item.isDivider ?? false ? ( + + ) : item.label ? ( + + {item.label} + {item.content} + + ) : ( + item.content + )} + + ))} + + + ) : undefined, + [getHref, hasWriteCapabilites, packageInfo, pkgkey, updateAvailable] + ); -type LayoutProps = PackageInfo & Pick & Pick; -export function DetailLayout(props: LayoutProps) { - const { name: packageName, version, icons, restrictWidth, title: packageTitle } = props; - const iconType = usePackageIconType({ packageName, version, icons }); - useBreadcrumbs('integration_details', { pkgTitle: packageTitle }); return ( - - - -
- - - - - - - - + + {packageInfo ? : null} + {packageInfoError ? ( + + } + error={packageInfoError} + /> + ) : isLoading || !packageInfo ? ( + + ) : ( + + )} + ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx index a802e35add7db..c329596384730 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx @@ -22,7 +22,7 @@ export const LeftColumn: FunctionComponent = ({ children, ...rest } export const CenterColumn: FunctionComponent = ({ children, ...rest }) => { return ( - + {children} ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx index 696af14604c5b..d8388a71556d6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { ScreenshotItem } from '../../../../types'; import { useLinks } from '../../hooks'; @@ -13,6 +14,29 @@ interface ScreenshotProps { images: ScreenshotItem[]; } +const getHorizontalPadding = (styledProps: any): number => + parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 2; +const getVerticalPadding = (styledProps: any): number => + parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 1.75; +const getPadding = (styledProps: any) => + styledProps.hascaption + ? `${styledProps.theme.eui.paddingSizes.xl} ${getHorizontalPadding( + styledProps + )}px ${getVerticalPadding(styledProps)}px` + : `${getHorizontalPadding(styledProps)}px ${getVerticalPadding(styledProps)}px`; +const ScreenshotsContainer = styled(EuiFlexGroup)` + background: linear-gradient(360deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%), + ${(styledProps) => styledProps.theme.eui.euiColorPrimary}; + padding: ${(styledProps) => getPadding(styledProps)}; + flex: 0 0 auto; + border-radius: ${(styledProps) => styledProps.theme.eui.euiBorderRadius}; +`; + +// fixes ie11 problems with nested flex items +const NestedEuiFlexItem = styled(EuiFlexItem)` + flex: 0 0 auto !important; +`; + export function Screenshots(props: ScreenshotProps) { const { toImage } = useLinks(); const { images } = props; @@ -21,36 +45,23 @@ export function Screenshots(props: ScreenshotProps) { const image = images[0]; const hasCaption = image.title ? true : false; - const getHorizontalPadding = (styledProps: any): number => - parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 2; - const getVerticalPadding = (styledProps: any): number => - parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 1.75; - const getPadding = (styledProps: any) => - hasCaption - ? `${styledProps.theme.eui.paddingSizes.xl} ${getHorizontalPadding( - styledProps - )}px ${getVerticalPadding(styledProps)}px` - : `${getHorizontalPadding(styledProps)}px ${getVerticalPadding(styledProps)}px`; - - const ScreenshotsContainer = styled(EuiFlexGroup)` - background: linear-gradient(360deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%), - ${(styledProps) => styledProps.theme.eui.euiColorPrimary}; - padding: ${(styledProps) => getPadding(styledProps)}; - flex: 0 0 auto; - border-radius: ${(styledProps) => styledProps.theme.eui.euiBorderRadius}; - `; - - // fixes ie11 problems with nested flex items - const NestedEuiFlexItem = styled(EuiFlexItem)` - flex: 0 0 auto !important; - `; return ( -

Screenshots

+

+ +

- + {hasCaption && ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx index 125289ce3ee8d..4832a89479026 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/settings_panel.tsx @@ -33,7 +33,7 @@ const NoteLabel = () => ( ); const UpdatesAvailableMsg = () => ( - + {entries(PanelDisplayNames).map(([panel, display]) => { - const Link = styled(EuiButtonEmpty).attrs({ - href: getHref('integration_details', { pkgkey: `${name}-${version}`, panel }), - })` - font-weight: ${(p) => - active === panel - ? p.theme.eui.euiFontWeightSemiBold - : p.theme.eui.euiFontWeightRegular}; - `; // Don't display usages tab as we haven't implemented this yet // FIXME: Restore when we implement usages page if (panel === 'usages' && (true || packageInstallStatus.status !== InstallStatus.installed)) @@ -50,7 +41,11 @@ export function SideNavLinks({ name, version, active }: NavLinkProps) { return (
- {display} + + {active === panel ? {display} : display} +
); })} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx index c378e5a47a9b9..363b1ede89e9e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx @@ -39,22 +39,26 @@ export const HeroCopy = memo(() => { ); }); +const Illustration = styled(EuiImage)` + margin-bottom: -68px; + width: 80%; +`; + export const HeroImage = memo(() => { const { toAssets } = useLinks(); const { uiSettings } = useCore(); const IS_DARK_THEME = uiSettings.get('theme:darkMode'); - const Illustration = styled(EuiImage).attrs((props) => ({ - alt: i18n.translate('xpack.ingestManager.epm.illustrationAltText', { - defaultMessage: 'Illustration of an integration', - }), - url: IS_DARK_THEME - ? toAssets('illustration_integrations_darkmode.svg') - : toAssets('illustration_integrations_lightmode.svg'), - }))` - margin-bottom: -68px; - width: 80%; - `; - - return ; + return ( + + ); }); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx index c68833c1b2d95..a8e4d0105066b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx @@ -61,7 +61,9 @@ export function EPMHomePage() { function InstalledPackages() { useBreadcrumbs('integrations_installed'); - const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages(); + const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({ + experimental: true, + }); const [selectedCategory, setSelectedCategory] = useState(''); const title = i18n.translate('xpack.ingestManager.epmList.installedTitle', { @@ -118,7 +120,8 @@ function AvailablePackages() { const queryParams = new URLSearchParams(useLocation().search); const initialCategory = queryParams.get('category') || ''; const [selectedCategory, setSelectedCategory] = useState(initialCategory); - const { data: categoryPackagesRes, isLoading: isLoadingPackages } = useGetPackages({ + const { data: allPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages(); + const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ category: selectedCategory, }); const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories(); @@ -126,7 +129,7 @@ function AvailablePackages() { categoryPackagesRes && categoryPackagesRes.response ? categoryPackagesRes.response : []; const title = i18n.translate('xpack.ingestManager.epmList.allTitle', { - defaultMessage: 'All integrations', + defaultMessage: 'Browse by category', }); const categories = [ @@ -135,13 +138,13 @@ function AvailablePackages() { title: i18n.translate('xpack.ingestManager.epmList.allPackagesFilterLinkText', { defaultMessage: 'All', }), - count: packages.length, + count: allPackagesRes?.response?.length || 0, }, ...(categoriesRes ? categoriesRes.response : []), ]; const controls = categories ? ( { @@ -156,7 +159,7 @@ function AvailablePackages() { return ( ; - allPackages: PackageList; -} - -export function SearchPackages({ searchTerm, localSearchRef, allPackages }: SearchPackagesProps) { - // this means the search index hasn't been built yet. - // i.e. the intial fetch of all packages hasn't finished - if (!localSearchRef.current) return
Still fetching matches. Try again in a moment.
; - - const matches = localSearchRef.current.search(searchTerm) as PackageList; - const matchingIds = matches.map((match) => match[searchIdField]); - const filtered = allPackages.filter((item) => matchingIds.includes(item[searchIdField])); - - return ; -} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx deleted file mode 100644 index fbdcaac01931b..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx +++ /dev/null @@ -1,33 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiText, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { PackageList } from '../../../../types'; -import { PackageListGrid } from '../../components/package_list_grid'; - -interface SearchResultsProps { - term: string; - results: PackageList; -} - -export function SearchResults({ term, results }: SearchResultsProps) { - const title = 'Search results'; - return ( - - - {results.length} results for "{term}" - - - } - /> - ); -} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 9cd8a75642296..170a9cedc08d9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -91,7 +91,9 @@ export { RequirementVersion, ScreenshotItem, ServiceName, + GetCategoriesRequest, GetCategoriesResponse, + GetPackagesRequest, GetPackagesResponse, GetLimitedPackagesResponse, GetInfoResponse, @@ -101,6 +103,7 @@ export { InstallStatus, InstallationStatus, Installable, + RegistryRelease, } from '../../../../common'; export * from './intra_app_route_state'; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index a50b3b13faeab..fe813f29b72e6 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -14,6 +14,7 @@ import { GetLimitedPackagesResponse, } from '../../../common'; import { + GetCategoriesRequestSchema, GetPackagesRequestSchema, GetFileRequestSchema, GetInfoRequestSchema, @@ -30,9 +31,12 @@ import { getLimitedPackages, } from '../../services/epm/packages'; -export const getCategoriesHandler: RequestHandler = async (context, request, response) => { +export const getCategoriesHandler: RequestHandler< + undefined, + TypeOf +> = async (context, request, response) => { try { - const res = await getCategories(); + const res = await getCategories(request.query); const body: GetCategoriesResponse = { response: res, success: true, @@ -54,7 +58,7 @@ export const getListHandler: RequestHandler< const savedObjectsClient = context.core.savedObjects.client; const res = await getPackages({ savedObjectsClient, - category: request.query.category, + ...request.query, }); const body: GetPackagesResponse = { response: res, diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts index ffaf0ce46c89a..b524a7b33923e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts @@ -15,6 +15,7 @@ import { deletePackageHandler, } from './handlers'; import { + GetCategoriesRequestSchema, GetPackagesRequestSchema, GetFileRequestSchema, GetInfoRequestSchema, @@ -26,7 +27,7 @@ export const registerRoutes = (router: IRouter) => { router.get( { path: EPM_API_ROUTES.CATEGORIES_PATTERN, - validate: false, + validate: GetCategoriesRequestSchema, options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getCategoriesHandler diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index ad9635cc02e06..78aa513d1a1dc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -17,8 +17,8 @@ function nameAsTitle(name: string) { return name.charAt(0).toUpperCase() + name.substr(1).toLowerCase(); } -export async function getCategories() { - return Registry.fetchCategories(); +export async function getCategories(options: Registry.CategoriesParams) { + return Registry.fetchCategories(options); } export async function getPackages( @@ -26,8 +26,8 @@ export async function getPackages( savedObjectsClient: SavedObjectsClientContract; } & Registry.SearchParams ) { - const { savedObjectsClient } = options; - const registryItems = await Registry.fetchList({ category: options.category }).then((items) => { + const { savedObjectsClient, experimental, category } = options; + const registryItems = await Registry.fetchList({ category, experimental }).then((items) => { return items.map((item) => Object.assign({}, item, { title: item.title || nameAsTitle(item.name) }) ); @@ -56,7 +56,7 @@ export async function getLimitedPackages(options: { savedObjectsClient: SavedObjectsClientContract; }): Promise { const { savedObjectsClient } = options; - const allPackages = await getPackages({ savedObjectsClient }); + const allPackages = await getPackages({ savedObjectsClient, experimental: true }); const installedPackages = allPackages.filter( (pkg) => (pkg.status = InstallationStatus.installed) ); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index 0393cabca8ba2..ea906517f6dec 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -26,6 +26,11 @@ export { ArchiveEntry } from './extract'; export interface SearchParams { category?: CategoryId; + experimental?: boolean; +} + +export interface CategoriesParams { + experimental?: boolean; } export const pkgToPkgKey = ({ name, version }: { name: string; version: string }) => @@ -34,19 +39,23 @@ export const pkgToPkgKey = ({ name, version }: { name: string; version: string } export async function fetchList(params?: SearchParams): Promise { const registryUrl = getRegistryUrl(); const url = new URL(`${registryUrl}/search`); - if (params && params.category) { - url.searchParams.set('category', params.category); + if (params) { + if (params.category) { + url.searchParams.set('category', params.category); + } + if (params.experimental) { + url.searchParams.set('experimental', params.experimental.toString()); + } } return fetchUrl(url.toString()).then(JSON.parse); } -export async function fetchFindLatestPackage( - packageName: string, - internal: boolean = true -): Promise { +export async function fetchFindLatestPackage(packageName: string): Promise { const registryUrl = getRegistryUrl(); - const url = new URL(`${registryUrl}/search?package=${packageName}&internal=${internal}`); + const url = new URL( + `${registryUrl}/search?package=${packageName}&internal=true&experimental=true` + ); const res = await fetchUrl(url.toString()); const searchResults = JSON.parse(res); if (searchResults.length) { @@ -66,9 +75,16 @@ export async function fetchFile(filePath: string): Promise { return getResponse(`${registryUrl}${filePath}`); } -export async function fetchCategories(): Promise { +export async function fetchCategories(params?: CategoriesParams): Promise { const registryUrl = getRegistryUrl(); - return fetchUrl(`${registryUrl}/categories`).then(JSON.parse); + const url = new URL(`${registryUrl}/categories`); + if (params) { + if (params.experimental) { + url.searchParams.set('experimental', params.experimental.toString()); + } + } + + return fetchUrl(url.toString()).then(JSON.parse); } export async function getArchiveInfo( diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts index 3ed6ee553a507..08f47a8f1caaa 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts @@ -5,9 +5,16 @@ */ import { schema } from '@kbn/config-schema'; +export const GetCategoriesRequestSchema = { + query: schema.object({ + experimental: schema.maybe(schema.boolean()), + }), +}; + export const GetPackagesRequestSchema = { query: schema.object({ category: schema.maybe(schema.string()), + experimental: schema.maybe(schema.boolean()), }), }; From 595e9c2d8d5d131f1ab2dfddfa2c33b8aa0a08cb Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 8 Jul 2020 14:08:53 -0400 Subject: [PATCH 10/10] [Ingest Manager] Fix agent config out of date display (#71103) --- .../common/openapi/spec_oas3.json | 3 -- .../common/types/models/agent.ts | 1 - .../sections/fleet/agent_list_page/index.tsx | 18 ++++++++--- .../server/saved_objects/index.ts | 1 - .../server/services/agent_config_update.ts | 6 +--- .../server/services/agents/reassign.ts | 1 - .../server/services/agents/update.ts | 32 ------------------- 7 files changed, 14 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index c374cbb3bb146..4b10dab5d1ae5 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -4146,9 +4146,6 @@ "config_revision": { "type": ["number", "null"] }, - "config_newest_revision": { - "type": "number" - }, "last_checkin": { "type": "string" }, diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 27f0c61685fd4..1f4718acc2c1f 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -81,7 +81,6 @@ interface AgentBase { default_api_key_id?: string; config_id?: string; config_revision?: number | null; - config_newest_revision?: number; last_checkin?: string; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index ec58789becb72..30204603e764c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiBasicTable, EuiButton, @@ -25,7 +25,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { CSSProperties } from 'styled-components'; import { AgentEnrollmentFlyout } from '../components'; -import { Agent } from '../../../types'; +import { Agent, AgentConfig } from '../../../types'; import { usePagination, useCapabilities, @@ -220,6 +220,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }); const agentConfigs = agentConfigsRequest.data ? agentConfigsRequest.data.items : []; + const agentConfigsIndexedById = useMemo(() => { + return agentConfigs.reduce((acc, config) => { + acc[config.id] = config; + + return acc; + }, {} as { [k: string]: AgentConfig }); + }, [agentConfigs]); const { isLoading: isAgentConfigsLoading } = agentConfigsRequest; const columns = [ @@ -271,9 +278,10 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
)} - {agent.config_revision && - agent.config_newest_revision && - agent.config_newest_revision > agent.config_revision && ( + {agent.config_id && + agent.config_revision && + agentConfigsIndexedById[agent.config_id] && + agentConfigsIndexedById[agent.config_id].revision > agent.config_revision && ( diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index b47cf4f7e7c3b..a5b5cc4337908 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -64,7 +64,6 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { last_updated: { type: 'date' }, last_checkin: { type: 'date' }, config_revision: { type: 'integer' }, - config_newest_revision: { type: 'integer' }, default_api_key_id: { type: 'keyword' }, default_api_key: { type: 'binary', index: false }, updated_at: { type: 'date' }, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts index 1cca165906732..3d40d128afda8 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts @@ -6,7 +6,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { generateEnrollmentAPIKey, deleteEnrollmentApiKeyForConfigId } from './api_keys'; -import { updateAgentsForConfigId, unenrollForConfigId } from './agents'; +import { unenrollForConfigId } from './agents'; import { outputService } from './output'; export async function agentConfigUpdateEventHandler( @@ -26,10 +26,6 @@ export async function agentConfigUpdateEventHandler( }); } - if (action === 'updated') { - await updateAgentsForConfigId(soClient, configId); - } - if (action === 'deleted') { await unenrollForConfigId(soClient, configId); await deleteEnrollmentApiKeyForConfigId(soClient, configId); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/reassign.ts b/x-pack/plugins/ingest_manager/server/services/agents/reassign.ts index f8142af376eb3..ecc2c987d04b6 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/reassign.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/reassign.ts @@ -23,6 +23,5 @@ export async function reassignAgent( await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { config_id: newConfigId, config_revision: null, - config_newest_revision: config.revision, }); } diff --git a/x-pack/plugins/ingest_manager/server/services/agents/update.ts b/x-pack/plugins/ingest_manager/server/services/agents/update.ts index ec7a42ff11b7a..11ad76fe81784 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/update.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/update.ts @@ -8,38 +8,6 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { listAgents } from './crud'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import { unenrollAgent } from './unenroll'; -import { agentConfigService } from '../agent_config'; - -export async function updateAgentsForConfigId( - soClient: SavedObjectsClientContract, - configId: string -) { - const config = await agentConfigService.get(soClient, configId); - if (!config) { - throw new Error('Config not found'); - } - let hasMore = true; - let page = 1; - while (hasMore) { - const { agents } = await listAgents(soClient, { - kuery: `${AGENT_SAVED_OBJECT_TYPE}.config_id:"${configId}"`, - page: page++, - perPage: 1000, - showInactive: true, - }); - if (agents.length === 0) { - hasMore = false; - break; - } - const agentUpdate = agents.map((agent) => ({ - id: agent.id, - type: AGENT_SAVED_OBJECT_TYPE, - attributes: { config_newest_revision: config.revision }, - })); - - await soClient.bulkUpdate(agentUpdate); - } -} export async function unenrollForConfigId(soClient: SavedObjectsClientContract, configId: string) { let hasMore = true;