diff --git a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index f219c199b1d87..85abe0571d116 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -302,6 +302,27 @@ exports[`AdvancedSettings should render normally 1`] = ` ], } } + showNoResultsMessage={true} + /> + `; @@ -420,6 +441,45 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` ], } } + showNoResultsMessage={true} + /> + `; diff --git a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js index 2c67da99b6bf7..7ce4341f59ed8 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js +++ b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js @@ -35,7 +35,7 @@ import { Form } from './components/form'; import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; import './advanced_settings.less'; -import { registerDefaultComponents, PAGE_TITLE_COMPONENT } from './components/default_component_registry'; +import { registerDefaultComponents, PAGE_TITLE_COMPONENT, PAGE_FOOTER_COMPONENT } from './components/default_component_registry'; import { getSettingsComponent } from './components/component_registry'; export class AdvancedSettings extends Component { @@ -51,6 +51,7 @@ export class AdvancedSettings extends Component { this.init(config); this.state = { query: parsedQuery, + footerQueryMatched: false, filteredSettings: this.mapSettings(Query.execute(parsedQuery, this.settings)), }; @@ -129,14 +130,22 @@ export class AdvancedSettings extends Component { clearQuery = () => { this.setState({ query: Query.parse(''), + footerQueryMatched: false, filteredSettings: this.groupedSettings, }); } + onFooterQueryMatchChange = (matched) => { + this.setState({ + footerQueryMatched: matched + }); + } + render() { - const { filteredSettings, query } = this.state; + const { filteredSettings, query, footerQueryMatched } = this.state; const PageTitle = getSettingsComponent(PAGE_TITLE_COMPONENT); + const PageFooter = getSettingsComponent(PAGE_FOOTER_COMPONENT); return (
@@ -162,7 +171,9 @@ export class AdvancedSettings extends Component { clearQuery={this.clearQuery} save={this.saveConfig} clear={this.clearConfig} + showNoResultsMessage={!footerQueryMatched} /> +
); } diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js index b88fb11d63bbc..221f8c2f82bf8 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js @@ -19,9 +19,12 @@ import { tryRegisterSettingsComponent } from './component_registry'; import { PageTitle } from './page_title'; +import { PageFooter } from './page_footer'; export const PAGE_TITLE_COMPONENT = 'advanced_settings_page_title'; +export const PAGE_FOOTER_COMPONENT = 'advanced_settings_page_footer'; export function registerDefaultComponents() { tryRegisterSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle); + tryRegisterSettingsComponent(PAGE_FOOTER_COMPONENT, PageFooter); } \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 16ea544028c83..a4ed24873f4d9 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -87,7 +87,7 @@ export class Field extends PureComponent { getEditableValue(type, value, defVal) { const val = (value === null || value === undefined) ? defVal : value; - switch(type) { + switch (type) { case 'array': return val.join(', '); case 'boolean': @@ -102,10 +102,10 @@ export class Field extends PureComponent { } getDisplayedDefaultValue(type, defVal) { - if(defVal === undefined || defVal === null || defVal === '') { + if (defVal === undefined || defVal === null || defVal === '') { return 'null'; } - switch(type) { + switch (type) { case 'array': return defVal.join(', '); default: @@ -193,7 +193,7 @@ export class Field extends PureComponent { } onImageChange = async (files) => { - if(!files.length) { + if (!files.length) { this.clearError(); this.setState({ unsavedValue: null, @@ -212,18 +212,18 @@ export class Field extends PureComponent { changeImage: true, unsavedValue: base64Image, }); - } catch(err) { + } catch (err) { toastNotifications.addDanger('Image could not be saved'); this.cancelChangeImage(); } } getImageAsBase64(file) { - if(!file instanceof File) { + if (!file instanceof File) { return null; } - const reader = new FileReader(); + const reader = new FileReader(); reader.readAsDataURL(file); return new Promise((resolve, reject) => { @@ -245,7 +245,7 @@ export class Field extends PureComponent { cancelChangeImage = () => { const { savedValue } = this.state; - if(this.changeImageForm) { + if (this.changeImageForm) { this.changeImageForm.fileInput.value = null; this.changeImageForm.handleChange(); } @@ -268,14 +268,14 @@ export class Field extends PureComponent { const { name, defVal, type } = this.props.setting; const { changeImage, savedValue, unsavedValue, isJsonArray } = this.state; - if(savedValue === unsavedValue) { + if (savedValue === unsavedValue) { return; } let valueToSave = unsavedValue; let isSameValue = false; - switch(type) { + switch (type) { case 'array': valueToSave = valueToSave.split(',').map(val => val.trim()); isSameValue = valueToSave.join(',') === defVal.join(','); @@ -295,10 +295,10 @@ export class Field extends PureComponent { await this.props.save(name, valueToSave); } - if(changeImage) { + if (changeImage) { this.cancelChangeImage(); } - } catch(e) { + } catch (e) { toastNotifications.addDanger(`Unable to save ${name}`); } this.setLoading(false); @@ -311,7 +311,7 @@ export class Field extends PureComponent { await this.props.clear(name); this.cancelChangeImage(); this.clearError(); - } catch(e) { + } catch (e) { toastNotifications.addDanger(`Unable to reset ${name}`); } this.setLoading(false); @@ -321,7 +321,7 @@ export class Field extends PureComponent { const { loading, changeImage, unsavedValue } = this.state; const { name, value, type, options, isOverridden } = setting; - switch(type) { + switch (type) { case 'boolean': return ( ); case 'image': - if(!isDefaultValue(setting) && !changeImage) { + if (!isDefaultValue(setting) && !changeImage) { return ( {setting.name} @@ -438,7 +438,7 @@ export class Field extends PureComponent { const defaultLink = this.renderResetToDefaultLink(setting); const imageLink = this.renderChangeImageLink(setting); - if(defaultLink || imageLink) { + if (defaultLink || imageLink) { return ( {defaultLink} @@ -462,8 +462,12 @@ export class Field extends PureComponent { } renderDescription(setting) { - return ( - + let description; + + if (React.isValidElement(setting.description)) { + description = setting.description; + } else { + description = (
+ ); + } + + return ( + + {description} {this.renderDefaultValue(setting)} ); @@ -478,14 +488,14 @@ export class Field extends PureComponent { renderDefaultValue(setting) { const { type, defVal } = setting; - if(isDefaultValue(setting)) { + if (isDefaultValue(setting)) { return; } return ( - { type === 'json' ? ( + {type === 'json' ? ( Default: ) : ( - Default: {this.getDisplayedDefaultValue(type, defVal)} + Default: {this.getDisplayedDefaultValue(type, defVal)} - ) } + )} ); @@ -508,7 +518,7 @@ export class Field extends PureComponent { renderResetToDefaultLink(setting) { const { ariaName, name } = setting; - if(isDefaultValue(setting)) { + if (isDefaultValue(setting)) { return; } return ( @@ -528,7 +538,7 @@ export class Field extends PureComponent { renderChangeImageLink(setting) { const { changeImage } = this.state; const { type, value, ariaName, name } = setting; - if(type !== 'image' || !value || changeImage) { + if (type !== 'image' || !value || changeImage) { return; } return ( diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/form/__snapshots__/form.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/form/__snapshots__/form.test.js.snap index 627f5d864d1d2..7d51699e975e7 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/form/__snapshots__/form.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/settings/components/form/__snapshots__/form.test.js.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Form should not render no settings message when instructed not to 1`] = ``; + exports[`Form should render no settings message when there are no settings 1`] = ` @@ -95,12 +96,23 @@ export class Form extends PureComponent { ); } + maybeRenderNoSettings(clearQuery) { + if (this.props.showNoResultsMessage) { + return ( + + No settings found (Clear search) + + ); + } + return null; + } + render() { const { settings, categories, categoryCounts, clearQuery } = this.props; const currentCategories = []; categories.forEach(category => { - if(settings[category] && settings[category].length) { + if (settings[category] && settings[category].length) { currentCategories.push(category); } }); @@ -112,11 +124,7 @@ export class Form extends PureComponent { return ( this.renderCategory(category, settings[category], categoryCounts[category]) // fix this ); - }) : ( - - No settings found (Clear search) - - ) + }) : this.maybeRenderNoSettings(clearQuery) } ); diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js index cd3b3f2db5fb3..fddaae79ec44e 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js @@ -69,9 +69,9 @@ const categoryCounts = { dashboard: 1, 'x-pack': 10, }; -const save = () => {}; -const clear = () => {}; -const clearQuery = () => {}; +const save = () => { }; +const clear = () => { }; +const clearQuery = () => { }; describe('Form', () => { it('should render normally', async () => { @@ -83,6 +83,7 @@ describe('Form', () => { save={save} clear={clear} clearQuery={clearQuery} + showNoResultsMessage={true} /> ); @@ -98,6 +99,23 @@ describe('Form', () => { save={save} clear={clear} clearQuery={clearQuery} + showNoResultsMessage={true} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + it('should not render no settings message when instructed not to', async () => { + const component = shallow( +
); diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap new file mode 100644 index 0000000000000..eea1003c8eb95 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageFooter should render normally 1`] = `""`; diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js new file mode 100644 index 0000000000000..2fae89ceb0380 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js @@ -0,0 +1,20 @@ +/* + * 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 { PageFooter } from './page_footer'; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js new file mode 100644 index 0000000000000..e55fbbae3b5f8 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js @@ -0,0 +1,20 @@ +/* + * 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 PageFooter = () => null; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js new file mode 100644 index 0000000000000..e4ac6af0a88fe --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; + +import { PageFooter } from './page_footer'; + +describe('PageFooter', () => { + it('should render normally', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/src/ui/public/management/index.js b/src/ui/public/management/index.js index 616600f1ac1de..62f9850c839f5 100644 --- a/src/ui/public/management/index.js +++ b/src/ui/public/management/index.js @@ -19,8 +19,15 @@ import { ManagementSection } from './section'; +export { + PAGE_TITLE_COMPONENT, + PAGE_FOOTER_COMPONENT, +} from '../../../core_plugins/kibana/public/management/sections/settings/components/default_component_registry'; + export { registerSettingsComponent } from '../../../core_plugins/kibana/public/management/sections/settings/components/component_registry'; +export { Field } from '../../../core_plugins/kibana/public/management/sections/settings/components/field/field'; + export const management = new ManagementSection('management', { display: 'Management' }); diff --git a/x-pack/plugins/xpack_main/common/constants.js b/x-pack/plugins/xpack_main/common/constants.js index d939038a3986b..d59a4ab9e5a4a 100644 --- a/x-pack/plugins/xpack_main/common/constants.js +++ b/x-pack/plugins/xpack_main/common/constants.js @@ -14,7 +14,7 @@ export const CONFIG_TELEMETRY = 'telemetry:optIn'; * @type {string} */ export const CONFIG_TELEMETRY_DESC = ( - 'Help us improve the Elastic Stack by providing basic feature usage statistics? We will never share this data outside of Elastic.' + 'Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic.' ); /** @@ -53,3 +53,8 @@ export const REPORT_INTERVAL_MS = 86400000; * Key for the localStorage service */ export const LOCALSTORAGE_KEY = 'xpack.data'; + +/** + * Link to the Elastic Telemetry privacy statement. + */ +export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/telemetry-privacy-statement`; diff --git a/x-pack/plugins/xpack_main/index.js b/x-pack/plugins/xpack_main/index.js index 1fe2393f61d36..6e6ed25893ced 100644 --- a/x-pack/plugins/xpack_main/index.js +++ b/x-pack/plugins/xpack_main/index.js @@ -22,6 +22,7 @@ import { CONFIG_TELEMETRY_DESC, } from './common/constants'; import { settingsRoute } from './server/routes/api/v1/settings'; +import mappings from './mappings.json'; export { callClusterFactory } from './server/lib/call_cluster_factory'; @@ -65,11 +66,13 @@ export const xpackMain = (kibana) => { }, uiExports: { + managementSections: ['plugins/xpack_main/views/management'], uiSettingDefaults: { [CONFIG_TELEMETRY]: { name: 'Telemetry opt-in', description: CONFIG_TELEMETRY_DESC, - value: false + value: false, + readonly: true, }, [XPACK_DEFAULT_ADMIN_EMAIL_UI_SETTING]: { name: 'Admin email', @@ -84,6 +87,7 @@ export const xpackMain = (kibana) => { return { telemetryUrl: config.get('xpack.xpack_main.telemetry.url'), telemetryEnabled: isTelemetryEnabled(config), + telemetryOptedIn: null, }; }, hacks: [ @@ -101,6 +105,7 @@ export const xpackMain = (kibana) => { raw: true, }); }, + mappings, }, init(server) { diff --git a/x-pack/plugins/xpack_main/mappings.json b/x-pack/plugins/xpack_main/mappings.json new file mode 100644 index 0000000000000..d83f7f5967630 --- /dev/null +++ b/x-pack/plugins/xpack_main/mappings.json @@ -0,0 +1,9 @@ +{ + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + } +} diff --git a/x-pack/plugins/xpack_main/public/components/index.js b/x-pack/plugins/xpack_main/public/components/index.js index c18cf4665f4ac..c8dc260717da0 100644 --- a/x-pack/plugins/xpack_main/public/components/index.js +++ b/x-pack/plugins/xpack_main/public/components/index.js @@ -11,3 +11,6 @@ export { AddLicense } from '../../../license_management/public/sections/license_ * For to link to management */ export { BASE_PATH as MANAGEMENT_BASE_PATH } from '../../../license_management/common/constants'; + +export { TelemetryForm } from './telemetry/telemetry_form'; +export { OptInExampleFlyout } from './telemetry/opt_in_details_component'; \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/opt_in_details_component.test.js.snap b/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/opt_in_details_component.test.js.snap new file mode 100644 index 0000000000000..19a9bbb169b35 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/opt_in_details_component.test.js.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OptInDetailsComponent renders as expected 1`] = ` + + + + +

+ Cluster statistics +

+
+ + + This is an example of the basic cluster statistics that we’ll collect. It includes the number of indices, shards, and nodes. It also includes high-level usage statistics, such as whether monitoring is turned on. + + +
+ + + + + + + +
+
+`; diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/telemetry_form.test.js.snap b/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/telemetry_form.test.js.snap new file mode 100644 index 0000000000000..a24872ec6c4fd --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/__snapshots__/telemetry_form.test.js.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TelemetryForm renders as expected 1`] = ` + + + + + + +

+ Usage Data +

+
+
+
+ + +

+ Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic. +

+

+ + See an example of what we collect + +

+

+ + Read our usage data privacy statement + +

+ , + "type": "boolean", + "value": false, + } + } + /> +
+
+
+`; diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_details_component.js b/x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.js similarity index 87% rename from x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_details_component.js rename to x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.js index f980a10604fbd..678b30d261466 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_details_component.js +++ b/x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.js @@ -58,7 +58,7 @@ export class OptInExampleFlyout extends Component { return ( - + ); @@ -79,7 +79,7 @@ export class OptInExampleFlyout extends Component { return ( - { JSON.stringify(data, null, 2) } + {JSON.stringify(data, null, 2)} ); } @@ -90,21 +90,22 @@ export class OptInExampleFlyout extends Component { -

Cluster Statistics

+

Cluster statistics

- This is an example of the basic cluster statistics we’ll gather, which includes number of indexes, - number of shards, number of nodes, and high-level usage statistics, such as whether monitoring is enabled. + This is an example of the basic cluster statistics that we’ll collect. + It includes the number of indices, shards, and nodes. + It also includes high-level usage statistics, such as whether monitoring is turned on.
- { this.renderBody(this.state) } + {this.renderBody(this.state)}
diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.test.js b/x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.test.js new file mode 100644 index 0000000000000..a649f932e025f --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/opt_in_details_component.test.js @@ -0,0 +1,14 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { OptInExampleFlyout } from './opt_in_details_component'; + +describe('OptInDetailsComponent', () => { + it('renders as expected', () => { + expect(shallow( ({ data: [] }))} onClose={jest.fn()} />)).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js new file mode 100644 index 0000000000000..1a7826f56d0d2 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js @@ -0,0 +1,138 @@ +/* + * 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, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiPanel, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { CONFIG_TELEMETRY_DESC, PRIVACY_STATEMENT_URL } from '../../../common/constants'; +import { OptInExampleFlyout } from './opt_in_details_component'; +import './telemetry_form.less'; +import { Field } from 'ui/management'; + +const SEARCH_TERMS = ['telemetry', 'usage', 'data', 'usage data']; + +export class TelemetryForm extends Component { + static propTypes = { + telemetryOptInProvider: PropTypes.object.isRequired, + query: PropTypes.object, + onQueryMatchChange: PropTypes.func.isRequired, + }; + + state = { + processing: false, + showExample: false, + queryMatches: null, + } + + componentWillReceiveProps(nextProps) { + const { + query + } = nextProps; + + const searchTerm = (query.text || '').toLowerCase(); + const searchTermMatches = SEARCH_TERMS.some(term => term.indexOf(searchTerm) >= 0); + + if (searchTermMatches !== this.state.queryMatches) { + this.setState({ + queryMatches: searchTermMatches + }, () => { + this.props.onQueryMatchChange(searchTermMatches); + }); + } + } + + render() { + const { + telemetryOptInProvider, + } = this.props; + + const { + showExample, + queryMatches, + } = this.state; + + if (queryMatches !== null && !queryMatches) { + return null; + } + + return ( + + {showExample && + telemetryOptInProvider.fetchExample()} onClose={this.toggleExample} /> + } + + + + + +

Usage Data

+
+
+
+ + +
+
+
+ ); + } + + renderDescription = () => ( + +

{CONFIG_TELEMETRY_DESC}

+

See an example of what we collect

+

+ + Read our usage data privacy statement + +

+
+ ) + + toggleOptIn = async () => { + const newOptInValue = !this.props.telemetryOptInProvider.getOptIn(); + + return new Promise((resolve, reject) => { + this.setState({ + enabled: newOptInValue, + processing: true + }, () => { + this.props.telemetryOptInProvider.setOptIn(newOptInValue).then(() => { + this.setState({ processing: false }); + resolve(); + }, (e) => { + // something went wrong + this.setState({ processing: false }); + reject(e); + }); + }); + }); + + } + + toggleExample = () => { + this.setState({ + showExample: !this.state.showExample + }); + } +} \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.less b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.less new file mode 100644 index 0000000000000..041b0c9461e23 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.less @@ -0,0 +1,3 @@ +.telemetryForm { + margin: 10px 6px 6px 6px; +} \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js new file mode 100644 index 0000000000000..53717aa0b15a2 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js @@ -0,0 +1,47 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { TelemetryForm } from './telemetry_form'; +import { TelemetryOptInProvider } from '../../services/telemetry_opt_in'; + +const buildTelemetryOptInProvider = () => { + const mockHttp = { + post: jest.fn() + }; + + function mockNotifier() { + this.notify = jest.fn(); + } + + const mockInjector = { + get: (key) => { + switch (key) { + case '$http': + return mockHttp; + case 'Notifier': + return mockNotifier; + default: + return null; + } + } + }; + + const chrome = { + addBasePath: (url) => url + }; + + return new TelemetryOptInProvider(mockInjector, chrome); +}; + +describe('TelemetryForm', () => { + it('renders as expected', () => { + expect(shallow( + ) + ).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/hacks/__tests__/telemetry.js b/x-pack/plugins/xpack_main/public/hacks/__tests__/telemetry.js index e027530cc08fe..20227b40359ae 100644 --- a/x-pack/plugins/xpack_main/public/hacks/__tests__/telemetry.js +++ b/x-pack/plugins/xpack_main/public/hacks/__tests__/telemetry.js @@ -13,6 +13,7 @@ uiModules.get('kibana') // disable stat reporting while running tests, // MockInjector used in these tests is not impacted .constant('telemetryEnabled', false) + .constant('telemetryOptedIn', null) .constant('telemetryUrl', 'not.a.valid.url.0'); const getMockInjector = ({ allowReport, lastReport }) => { @@ -21,9 +22,7 @@ const getMockInjector = ({ allowReport, lastReport }) => { get: sinon.stub().returns({ lastReport: lastReport }), set: sinon.stub() }); - get.withArgs('config').returns({ - get: () => allowReport - }); + get.withArgs('telemetryOptedIn').returns(allowReport); const mockHttp = (req) => { return req; }; diff --git a/x-pack/plugins/xpack_main/public/hacks/telemetry.js b/x-pack/plugins/xpack_main/public/hacks/telemetry.js index 66abbdbaee9a0..4b03f1c951118 100644 --- a/x-pack/plugins/xpack_main/public/hacks/telemetry.js +++ b/x-pack/plugins/xpack_main/public/hacks/telemetry.js @@ -6,7 +6,6 @@ import Promise from 'bluebird'; import { - CONFIG_TELEMETRY, REPORT_INTERVAL_MS, LOCALSTORAGE_KEY, } from '../../common/constants'; @@ -19,9 +18,9 @@ export class Telemetry { */ constructor($injector, fetchTelemetry) { this._storage = $injector.get('localStorage'); - this._config = $injector.get('config'); this._$http = $injector.get('$http'); this._telemetryUrl = $injector.get('telemetryUrl'); + this._telemetryOptedIn = $injector.get('telemetryOptedIn'); this._attributes = this._storage.get(LOCALSTORAGE_KEY) || {}; this._fetchTelemetry = fetchTelemetry; } @@ -42,8 +41,8 @@ export class Telemetry { * Check time interval passage */ _checkReportStatus() { - // check if opt-in for telemetry is enabled in config - if (this._config.get(CONFIG_TELEMETRY, false)) { + // check if opt-in for telemetry is enabled + if (this._telemetryOptedIn) { // If the last report is empty it means we've never sent telemetry and // now is the time to send it. if (!this._get('lastReport')) { @@ -78,13 +77,13 @@ export class Telemetry { }); }) .then(response => { - // we sent a report, so we need to record and store the current time stamp + // we sent a report, so we need to record and store the current time stamp this._set('lastReport', Date.now()); this._saveToBrowser(); return response; }) .catch(() => { - // no ajaxErrorHandlers for telemetry + // no ajaxErrorHandlers for telemetry return Promise.resolve(null); }); } diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/click_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/click_banner.js index 21b3ad9ccafe3..3ef092bde18ec 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/click_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/click_banner.js @@ -6,11 +6,54 @@ import expect from 'expect.js'; import sinon from 'sinon'; +import { uiModules } from 'ui/modules'; + +uiModules.get('kibana') + // disable stat reporting while running tests, + // MockInjector used in these tests is not impacted + .constant('Notifier', function mockNotifier() { this.notify = sinon.stub(); }) + .constant('telemetryOptedIn', null); -import { CONFIG_TELEMETRY } from '../../../../common/constants'; import { clickBanner, } from '../click_banner'; +import { TelemetryOptInProvider } from '../../../services/telemetry_opt_in'; + +const getMockInjector = ({ simulateFailure }) => { + const get = sinon.stub(); + + get.withArgs('telemetryOptedIn').returns(null); + get.withArgs('Notifier').returns(function mockNotifier() { this.notify = sinon.stub(); }); + + const mockHttp = { + post: sinon.stub() + }; + + if (simulateFailure) { + mockHttp.post.returns(Promise.reject(new Error('something happened'))); + } else { + mockHttp.post.returns(Promise.resolve({})); + } + + get.withArgs('$http').returns(mockHttp); + + return { get }; +}; + +const getTelemetryOptInProvider = ({ simulateFailure = false, simulateError = false } = {}) => { + const injector = getMockInjector({ simulateFailure }); + const chrome = { + addBasePath: (url) => url + }; + + const provider = new TelemetryOptInProvider(injector, chrome); + + if (simulateError) { + provider.setOptIn = () => Promise.reject('unhandled error'); + } + + return provider; +}; describe('click_banner', () => { @@ -18,17 +61,15 @@ describe('click_banner', () => { const banners = { remove: sinon.spy() }; - const config = { - set: sinon.stub() - }; + + const telemetryOptInProvider = getTelemetryOptInProvider(); + const bannerId = 'bruce-banner'; const optIn = true; - config.set.withArgs(CONFIG_TELEMETRY, true).returns(Promise.resolve(true)); - - await clickBanner(bannerId, config, optIn, { _banners: banners }); + await clickBanner(bannerId, telemetryOptInProvider, optIn, { _banners: banners }); - expect(config.set.calledOnce).to.be(true); + expect(telemetryOptInProvider.getOptIn()).to.be(optIn); expect(banners.remove.calledOnce).to.be(true); expect(banners.remove.calledWith(bannerId)).to.be(true); }); @@ -40,17 +81,13 @@ describe('click_banner', () => { const banners = { remove: sinon.spy() }; - const config = { - set: sinon.stub() - }; + const telemetryOptInProvider = getTelemetryOptInProvider({ simulateFailure: true }); const bannerId = 'bruce-banner'; const optIn = true; - config.set.withArgs(CONFIG_TELEMETRY, true).returns(Promise.resolve(false)); - - await clickBanner(bannerId, config, optIn, { _banners: banners, _toastNotifications: toastNotifications }); + await clickBanner(bannerId, telemetryOptInProvider, optIn, { _banners: banners, _toastNotifications: toastNotifications }); - expect(config.set.calledOnce).to.be(true); + expect(telemetryOptInProvider.getOptIn()).to.be(null); expect(toastNotifications.addDanger.calledOnce).to.be(true); expect(banners.remove.notCalled).to.be(true); }); @@ -62,17 +99,13 @@ describe('click_banner', () => { const banners = { remove: sinon.spy() }; - const config = { - set: sinon.stub() - }; + const telemetryOptInProvider = getTelemetryOptInProvider({ simulateError: true }); const bannerId = 'bruce-banner'; const optIn = false; - config.set.withArgs(CONFIG_TELEMETRY, false).returns(Promise.reject()); - - await clickBanner(bannerId, config, optIn, { _banners: banners, _toastNotifications: toastNotifications }); + await clickBanner(bannerId, telemetryOptInProvider, optIn, { _banners: banners, _toastNotifications: toastNotifications }); - expect(config.set.calledOnce).to.be(true); + expect(telemetryOptInProvider.getOptIn()).to.be(null); expect(toastNotifications.addDanger.calledOnce).to.be(true); expect(banners.remove.notCalled).to.be(true); }); diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/handle_old_settings.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/handle_old_settings.js index 178bfb9cdfa2a..3ceb31cb2eb30 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/handle_old_settings.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/handle_old_settings.js @@ -9,67 +9,143 @@ import sinon from 'sinon'; import { CONFIG_TELEMETRY } from '../../../../common/constants'; import { handleOldSettings } from '../handle_old_settings'; +import { TelemetryOptInProvider } from '../../../services/telemetry_opt_in'; + +const getTelemetryOptInProvider = (enabled, { simulateFailure = false } = {}) => { + const $http = { + post: async () => { + if (simulateFailure) { + return Promise.reject(new Error('something happened')); + } + return {}; + } + }; + + const chrome = { + addBasePath: url => url + }; + + const $injector = { + get: (key) => { + if (key === '$http') { + return $http; + } + if (key === 'telemetryOptedIn') { + return enabled; + } + if (key === 'Notifier') { + return function mockNotifier() { + this.notify = sinon.stub(); + }; + } + throw new Error(`unexpected mock injector usage for ${key}`); + } + }; + + return new TelemetryOptInProvider($injector, chrome); +}; describe('handle_old_settings', () => { - it('re-uses old setting and stays opted in', async () => { + it('re-uses old "allowReport" setting and stays opted in', async () => { const config = { get: sinon.stub(), remove: sinon.spy(), set: sinon.stub(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null); + expect(telemetryOptInProvider.getOptIn()).to.be(null); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(true); config.set.withArgs(CONFIG_TELEMETRY, true).returns(Promise.resolve(true)); - expect(await handleOldSettings(config)).to.be(false); + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(false); + + expect(config.get.calledTwice).to.be(true); + expect(config.set.called).to.be(false); - expect(config.get.calledOnce).to.be(true); - expect(config.set.calledOnce).to.be(true); - expect(config.set.getCall(0).args).to.eql([ CONFIG_TELEMETRY, true ]); - expect(config.remove.calledTwice).to.be(true); + expect(config.remove.calledThrice).to.be(true); expect(config.remove.getCall(0).args[0]).to.be('xPackMonitoring:allowReport'); expect(config.remove.getCall(1).args[0]).to.be('xPackMonitoring:showBanner'); + expect(config.remove.getCall(2).args[0]).to.be(CONFIG_TELEMETRY); + + expect(telemetryOptInProvider.getOptIn()).to.be(true); }); - it('re-uses old setting and stays opted out', async () => { + it('re-uses old "telemetry:optIn" setting and stays opted in', async () => { const config = { get: sinon.stub(), remove: sinon.spy(), set: sinon.stub(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null); + expect(telemetryOptInProvider.getOptIn()).to.be(null); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(false); - config.set.withArgs(CONFIG_TELEMETRY, false).returns(Promise.resolve(true)); + config.get.withArgs(CONFIG_TELEMETRY, null).returns(true); + + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(false); - expect(await handleOldSettings(config)).to.be(false); + expect(config.get.calledTwice).to.be(true); + expect(config.set.called).to.be(false); - expect(config.get.calledOnce).to.be(true); - expect(config.set.calledOnce).to.be(true); - expect(config.set.getCall(0).args).to.eql([ CONFIG_TELEMETRY, false ]); - expect(config.remove.calledTwice).to.be(true); + expect(config.remove.calledThrice).to.be(true); expect(config.remove.getCall(0).args[0]).to.be('xPackMonitoring:allowReport'); expect(config.remove.getCall(1).args[0]).to.be('xPackMonitoring:showBanner'); + expect(config.remove.getCall(2).args[0]).to.be(CONFIG_TELEMETRY); + + expect(telemetryOptInProvider.getOptIn()).to.be(true); }); - it('re-uses old setting and stays opted out', async () => { + it('re-uses old "allowReport" setting and stays opted out', async () => { const config = { get: sinon.stub(), remove: sinon.spy(), set: sinon.stub(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null); + expect(telemetryOptInProvider.getOptIn()).to.be(null); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(false); config.set.withArgs(CONFIG_TELEMETRY, false).returns(Promise.resolve(true)); - expect(await handleOldSettings(config)).to.be(false); + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(false); + + expect(config.get.calledTwice).to.be(true); + expect(config.set.called).to.be(false); + expect(config.remove.calledThrice).to.be(true); + expect(config.remove.getCall(0).args[0]).to.be('xPackMonitoring:allowReport'); + expect(config.remove.getCall(1).args[0]).to.be('xPackMonitoring:showBanner'); + expect(config.remove.getCall(2).args[0]).to.be(CONFIG_TELEMETRY); + + expect(telemetryOptInProvider.getOptIn()).to.be(false); + }); + + it('re-uses old "telemetry:optIn" setting and stays opted out', async () => { + const config = { + get: sinon.stub(), + remove: sinon.spy(), + set: sinon.stub(), + }; + + const telemetryOptInProvider = getTelemetryOptInProvider(null); + + config.get.withArgs(CONFIG_TELEMETRY, null).returns(false); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(true); + + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(false); - expect(config.get.calledOnce).to.be(true); - expect(config.set.calledOnce).to.be(true); - expect(config.set.getCall(0).args).to.eql([ CONFIG_TELEMETRY, false ]); - expect(config.remove.calledTwice).to.be(true); + expect(config.get.calledTwice).to.be(true); + expect(config.set.called).to.be(false); + expect(config.remove.calledThrice).to.be(true); expect(config.remove.getCall(0).args[0]).to.be('xPackMonitoring:allowReport'); expect(config.remove.getCall(1).args[0]).to.be('xPackMonitoring:showBanner'); + expect(config.remove.getCall(2).args[0]).to.be(CONFIG_TELEMETRY); + + expect(telemetryOptInProvider.getOptIn()).to.be(false); }); it('acknowledges users old setting even if re-setting fails', async () => { @@ -78,15 +154,17 @@ describe('handle_old_settings', () => { set: sinon.stub(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null, { simulateFailure: true }); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(false); + //todo: make the new version of this fail! config.set.withArgs(CONFIG_TELEMETRY, false).returns(Promise.resolve(false)); // note: because it doesn't remove the old settings _and_ returns false, there's no risk of suddenly being opted in - expect(await handleOldSettings(config)).to.be(false); + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(false); - expect(config.get.calledOnce).to.be(true); - expect(config.set.calledOnce).to.be(true); - expect(config.set.getCall(0).args).to.eql([ CONFIG_TELEMETRY, false ]); + expect(config.get.calledTwice).to.be(true); + expect(config.set.called).to.be(false); }); it('removes show banner setting and presents user with choice', async () => { @@ -95,12 +173,14 @@ describe('handle_old_settings', () => { remove: sinon.spy(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(null); config.get.withArgs('xPackMonitoring:showBanner', null).returns(false); - expect(await handleOldSettings(config)).to.be(true); + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(true); - expect(config.get.calledTwice).to.be(true); + expect(config.get.calledThrice).to.be(true); expect(config.remove.calledOnce).to.be(true); expect(config.remove.getCall(0).args[0]).to.be('xPackMonitoring:showBanner'); }); @@ -110,12 +190,14 @@ describe('handle_old_settings', () => { get: sinon.stub(), }; + const telemetryOptInProvider = getTelemetryOptInProvider(null); + config.get.withArgs('xPackMonitoring:allowReport', null).returns(null); config.get.withArgs('xPackMonitoring:showBanner', null).returns(null); - expect(await handleOldSettings(config)).to.be(true); + expect(await handleOldSettings(config, telemetryOptInProvider)).to.be(true); - expect(config.get.calledTwice).to.be(true); + expect(config.get.calledThrice).to.be(true); }); -}); \ No newline at end of file +}); diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/should_show_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/should_show_banner.js index a215d21e7cdbf..33fde83241f8a 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/should_show_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/__tests__/should_show_banner.js @@ -9,11 +9,37 @@ import sinon from 'sinon'; import { CONFIG_TELEMETRY } from '../../../../common/constants'; import { shouldShowBanner } from '../should_show_banner'; +import { TelemetryOptInProvider } from '../../../services/telemetry_opt_in'; + +const getMockInjector = ({ telemetryEnabled }) => { + const get = sinon.stub(); + + get.withArgs('telemetryOptedIn').returns(telemetryEnabled); + get.withArgs('Notifier').returns(function mockNotifier() { this.notify = sinon.stub(); }); + + const mockHttp = { + post: sinon.stub() + }; + + get.withArgs('$http').returns(mockHttp); + + return { get }; +}; + +const getTelemetryOptInProvider = ({ telemetryEnabled = null } = {}) => { + const injector = getMockInjector({ telemetryEnabled }); + const chrome = { + addBasePath: (url) => url + }; + + return new TelemetryOptInProvider(injector, chrome); +}; describe('should_show_banner', () => { it('returns whatever handleOldSettings does when telemetry opt-in setting is unset', async () => { const config = { get: sinon.stub() }; + const telemetryOptInProvider = getTelemetryOptInProvider(); const handleOldSettingsTrue = sinon.stub(); const handleOldSettingsFalse = sinon.stub(); @@ -21,13 +47,13 @@ describe('should_show_banner', () => { handleOldSettingsTrue.returns(Promise.resolve(true)); handleOldSettingsFalse.returns(Promise.resolve(false)); - const showBannerTrue = await shouldShowBanner(config, { _handleOldSettings: handleOldSettingsTrue }); - const showBannerFalse = await shouldShowBanner(config, { _handleOldSettings: handleOldSettingsFalse }); + const showBannerTrue = await shouldShowBanner(telemetryOptInProvider, config, { _handleOldSettings: handleOldSettingsTrue }); + const showBannerFalse = await shouldShowBanner(telemetryOptInProvider, config, { _handleOldSettings: handleOldSettingsFalse }); expect(showBannerTrue).to.be(true); expect(showBannerFalse).to.be(false); - expect(config.get.calledTwice).to.be(true); + expect(config.get.callCount).to.be(0); expect(handleOldSettingsTrue.calledOnce).to.be(true); expect(handleOldSettingsFalse.calledOnce).to.be(true); }); @@ -35,17 +61,17 @@ describe('should_show_banner', () => { it('returns false if telemetry opt-in setting is set to true', async () => { const config = { get: sinon.stub() }; - config.get.withArgs(CONFIG_TELEMETRY, null).returns(true); + const telemetryOptInProvider = getTelemetryOptInProvider({ telemetryEnabled: true }); - expect(await shouldShowBanner(config)).to.be(false); + expect(await shouldShowBanner(telemetryOptInProvider, config)).to.be(false); }); it('returns false if telemetry opt-in setting is set to false', async () => { const config = { get: sinon.stub() }; - config.get.withArgs(CONFIG_TELEMETRY, null).returns(false); + const telemetryOptInProvider = getTelemetryOptInProvider({ telemetryEnabled: false }); - expect(await shouldShowBanner(config)).to.be(false); + expect(await shouldShowBanner(telemetryOptInProvider, config)).to.be(false); }); }); \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/click_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/click_banner.js index ce0f2a98b416c..efc07f49c5864 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/click_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/click_banner.js @@ -12,22 +12,25 @@ import { } from 'ui/notify'; import { EuiText } from '@elastic/eui'; -import { CONFIG_TELEMETRY } from '../../../common/constants'; - /** * Handle clicks from the user on the opt-in banner. * * @param {String} bannerId Banner ID to close upon success. - * @param {Object} config Advanced settings configuration to set opt-in. + * @param {Object} telemetryOptInProvider the telemetry opt-in provider * @param {Boolean} optIn {@code true} to opt into telemetry. * @param {Object} _banners Singleton banners. Can be overridden for tests. * @param {Object} _toastNotifications Singleton toast notifications. Can be overridden for tests. */ -export async function clickBanner(bannerId, config, optIn, { _banners = banners, _toastNotifications = toastNotifications } = { }) { +export async function clickBanner( + bannerId, + telemetryOptInProvider, + optIn, + { _banners = banners, _toastNotifications = toastNotifications } = {}) { + let set = false; try { - set = await config.set(CONFIG_TELEMETRY, Boolean(optIn)); + set = await telemetryOptInProvider.setOptIn(optIn); } catch (err) { // set is already false console.log('Unexpected error while trying to save setting.', err); @@ -37,10 +40,10 @@ export async function clickBanner(bannerId, config, optIn, { _banners = banners, _banners.remove(bannerId); } else { _toastNotifications.addDanger({ - title: 'Advanced Setting Error', + title: 'Telemetry Error', text: ( -

Unable to save advanced setting.

+

Unable to save telemetry preference.

Check that Kibana and Elasticsearch are still running, then try again. diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/handle_old_settings.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/handle_old_settings.js index fba5a22d5b16f..4188676bad1e0 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/handle_old_settings.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/handle_old_settings.js @@ -14,19 +14,31 @@ import { CONFIG_TELEMETRY } from '../../../common/constants'; * @param {Object} config The advanced settings config object. * @return {Boolean} {@code true} if the banner should still be displayed. {@code false} if the banner should not be displayed. */ -export async function handleOldSettings(config) { +export async function handleOldSettings(config, telemetryOptInProvider) { const CONFIG_ALLOW_REPORT = 'xPackMonitoring:allowReport'; const CONFIG_SHOW_BANNER = 'xPackMonitoring:showBanner'; - const oldSetting = config.get(CONFIG_ALLOW_REPORT, null); + const oldAllowReportSetting = config.get(CONFIG_ALLOW_REPORT, null); + const oldTelemetrySetting = config.get(CONFIG_TELEMETRY, null); + + let legacyOptInValue = null; + + if (typeof oldTelemetrySetting === 'boolean') { + legacyOptInValue = oldTelemetrySetting; + } else if (typeof oldAllowReportSetting === 'boolean') { + legacyOptInValue = oldAllowReportSetting; + } + + if (legacyOptInValue !== null) { + try { + await telemetryOptInProvider.setOptIn(legacyOptInValue); - if (oldSetting !== null) { - if (await config.set(CONFIG_TELEMETRY, Boolean(oldSetting))) { // delete old keys once we've successfully changed the setting (if it fails, we just wait until next time) config.remove(CONFIG_ALLOW_REPORT); config.remove(CONFIG_SHOW_BANNER); + config.remove(CONFIG_TELEMETRY); + } finally { + return false; } - - return false; } const oldShowSetting = config.get(CONFIG_SHOW_BANNER, null); @@ -36,4 +48,4 @@ export async function handleOldSettings(config) { } return true; -} \ No newline at end of file +} diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/inject_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/inject_banner.js index 7fd8a5027d407..d3142fd3e2dac 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/inject_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/inject_banner.js @@ -9,6 +9,7 @@ import { PathProvider } from 'plugins/xpack_main/services/path'; import { fetchTelemetry } from '../fetch_telemetry'; import { renderBanner } from './render_banner'; import { shouldShowBanner } from './should_show_banner'; +import { TelemetryOptInProvider } from '../../services/telemetry_opt_in'; /** * Add the Telemetry opt-in banner if the user has not already made a decision. @@ -21,6 +22,7 @@ import { shouldShowBanner } from './should_show_banner'; async function asyncInjectBanner($injector) { const telemetryEnabled = $injector.get('telemetryEnabled'); const Private = $injector.get('Private'); + const telemetryOptInProvider = Private(TelemetryOptInProvider); const config = $injector.get('config'); // no banner if the server config has telemetry disabled @@ -39,10 +41,10 @@ async function asyncInjectBanner($injector) { } // determine if the banner should be displayed - if (await shouldShowBanner(config)) { - const $http = $injector.get('$http'); + if (await shouldShowBanner(telemetryOptInProvider, config)) { + const $http = $injector.get("$http"); - renderBanner(config, () => fetchTelemetry($http)); + renderBanner(telemetryOptInProvider, () => fetchTelemetry($http)); } } diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_banner_component.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_banner_component.js index 805a9541191f5..30d19f4c32478 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_banner_component.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/opt_in_banner_component.js @@ -17,8 +17,8 @@ import { EuiText, } from '@elastic/eui'; -import { CONFIG_TELEMETRY_DESC } from '../../../common/constants'; -import { OptInExampleFlyout } from './opt_in_details_component'; +import { CONFIG_TELEMETRY_DESC, PRIVACY_STATEMENT_URL } from '../../../common/constants'; +import { OptInExampleFlyout } from '../../components'; /** * React component for displaying the Telemetry opt-in banner. @@ -63,7 +63,7 @@ export class OptInBanner extends Component { )} or read our {( telemetry privacy statement @@ -84,7 +84,7 @@ export class OptInBanner extends Component { } else { title = ( - { CONFIG_TELEMETRY_DESC } {( + {CONFIG_TELEMETRY_DESC} {( this.setState({ showDetails: true })}> Read more @@ -95,8 +95,8 @@ export class OptInBanner extends Component { return ( - { details } - { flyoutDetails } + {details} + {flyoutDetails} diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/render_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/render_banner.js index 59ccced7ac714..1fa1287cc2d98 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/render_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/render_banner.js @@ -14,15 +14,15 @@ import { OptInBanner } from './opt_in_banner_component'; /** * Render the Telemetry Opt-in banner. * - * @param {Object} config The advanced settings config. + * @param {Object} telemetryOptInProvider The telemetry opt-in provider. * @param {Function} fetchTelemetry Function to pull telemetry on demand. * @param {Object} _banners Banners singleton, which can be overridden for tests. */ -export function renderBanner(config, fetchTelemetry, { _banners = banners } = { }) { +export function renderBanner(telemetryOptInProvider, fetchTelemetry, { _banners = banners } = {}) { const bannerId = _banners.add({ component: ( clickBanner(bannerId, config, optIn)} + optInClick={optIn => clickBanner(bannerId, telemetryOptInProvider, optIn)} fetchTelemetry={fetchTelemetry} /> ), diff --git a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/should_show_banner.js b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/should_show_banner.js index 4e224173997b6..5685132a95061 100644 --- a/x-pack/plugins/xpack_main/public/hacks/welcome_banner/should_show_banner.js +++ b/x-pack/plugins/xpack_main/public/hacks/welcome_banner/should_show_banner.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CONFIG_TELEMETRY } from '../../../common/constants'; import { handleOldSettings } from './handle_old_settings'; /** @@ -16,6 +15,6 @@ import { handleOldSettings } from './handle_old_settings'; * @param {Object} _handleOldSettings handleOldSettings function, but overridable for tests. * @return {Boolean} {@code true} if the banner should be displayed. {@code false} otherwise. */ -export async function shouldShowBanner(config, { _handleOldSettings = handleOldSettings } = { }) { - return config.get(CONFIG_TELEMETRY, null) === null && await _handleOldSettings(config); -} \ No newline at end of file +export async function shouldShowBanner(telemetryOptInProvider, config, { _handleOldSettings = handleOldSettings } = {}) { + return telemetryOptInProvider.getOptIn() === null && await _handleOldSettings(config, telemetryOptInProvider); +} diff --git a/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.js b/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.js new file mode 100644 index 0000000000000..b4070e4587f87 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.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. + */ + +import moment from 'moment'; + +export function TelemetryOptInProvider($injector, chrome) { + + const Notifier = $injector.get('Notifier'); + const notify = new Notifier(); + let currentOptInStatus = $injector.get('telemetryOptedIn'); + + return { + getOptIn: () => currentOptInStatus, + setOptIn: async (enabled) => { + const $http = $injector.get('$http'); + + try { + await $http.post(chrome.addBasePath('/api/telemetry/v1/optIn'), { enabled }); + currentOptInStatus = enabled; + } catch (error) { + notify.error(error); + return false; + } + + return true; + }, + fetchExample: async () => { + const $http = $injector.get('$http'); + return $http.post(chrome.addBasePath(`/api/telemetry/v1/clusters/_stats`), { + timeRange: { + min: moment().subtract(20, 'minutes').toISOString(), + max: moment().toISOString() + } + }); + } + }; +} diff --git a/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.test.js b/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.test.js new file mode 100644 index 0000000000000..60c08e5a338e5 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/telemetry_opt_in.test.js @@ -0,0 +1,89 @@ +/* + * 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 { TelemetryOptInProvider } from "./telemetry_opt_in"; + +describe('TelemetryOptInProvider', () => { + const setup = ({ optedIn, simulatePostError }) => { + const mockHttp = { + post: jest.fn(async () => { + if (simulatePostError) { + return Promise.reject("Something happened"); + } + }) + }; + + const mockChrome = { + addBasePath: (url) => url + }; + + class MockNotifier { + constructor() { + this.error = jest.fn(); + } + } + + const mockInjector = { + get: (key) => { + switch (key) { + case 'telemetryOptedIn': { + return optedIn; + } + case 'Notifier': { + return MockNotifier; + } + case '$http': { + return mockHttp; + } + default: + throw new Error('unexpected injector request: ' + key); + } + } + }; + + const provider = new TelemetryOptInProvider(mockInjector, mockChrome); + return { + provider, + mockHttp, + }; + }; + + + it('should return the current opt-in status', () => { + const { provider: optedInProvider } = setup({ optedIn: true }); + expect(optedInProvider.getOptIn()).toEqual(true); + + const { provider: optedOutProvider } = setup({ optedIn: false }); + expect(optedOutProvider.getOptIn()).toEqual(false); + }); + + it('should allow an opt-out to take place', async () => { + const { provider, mockHttp } = setup({ optedIn: true }); + await provider.setOptIn(false); + + expect(mockHttp.post).toHaveBeenCalledWith(`/api/telemetry/v1/optIn`, { enabled: false }); + + expect(provider.getOptIn()).toEqual(false); + }); + + it('should allow an opt-in to take place', async () => { + const { provider, mockHttp } = setup({ optedIn: false }); + await provider.setOptIn(true); + + expect(mockHttp.post).toHaveBeenCalledWith(`/api/telemetry/v1/optIn`, { enabled: true }); + + expect(provider.getOptIn()).toEqual(true); + }); + + it('should gracefully handle errors', async () => { + const { provider, mockHttp } = setup({ optedIn: false, simulatePostError: true }); + await provider.setOptIn(true); + + expect(mockHttp.post).toHaveBeenCalledWith(`/api/telemetry/v1/optIn`, { enabled: true }); + + // opt-in change should not be reflected + expect(provider.getOptIn()).toEqual(false); + }); +}); \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/views/management/index.js b/x-pack/plugins/xpack_main/public/views/management/index.js new file mode 100644 index 0000000000000..0ed6fe09ef80a --- /dev/null +++ b/x-pack/plugins/xpack_main/public/views/management/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './management'; diff --git a/x-pack/plugins/xpack_main/public/views/management/management.js b/x-pack/plugins/xpack_main/public/views/management/management.js new file mode 100644 index 0000000000000..8c244f8ae933f --- /dev/null +++ b/x-pack/plugins/xpack_main/public/views/management/management.js @@ -0,0 +1,22 @@ +/* + * 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 from 'react'; +import routes from 'ui/routes'; + +import { registerSettingsComponent, PAGE_FOOTER_COMPONENT } from 'ui/management'; +import { TelemetryOptInProvider } from '../../services/telemetry_opt_in'; +import { TelemetryForm } from '../../components'; + +routes.defaults(/\/management/, { + resolve: { + telemetryManagementSection: function (Private) { + const telemetryOptInProvider = Private(TelemetryOptInProvider); + const Component = (props) => ; + + registerSettingsComponent(PAGE_FOOTER_COMPONENT, Component, true); + } + } +}); diff --git a/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js b/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js index 283f3e09b9d60..f75ec5678f1c6 100644 --- a/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js +++ b/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js @@ -9,15 +9,40 @@ import expect from 'expect.js'; import { replaceInjectedVars } from '../replace_injected_vars'; +const buildRequest = (telemetryOptedIn = null) => { + const get = sinon.stub(); + if (telemetryOptedIn === null) { + get.withArgs('telemetry', 'telemetry').returns(Promise.reject(new Error('not found exception'))); + } else { + get.withArgs('telemetry', 'telemetry').returns(Promise.resolve({ attributes: { enabled: telemetryOptedIn } })); + } + + return { + getSavedObjectsClient: () => { + return { + get, + create: sinon.stub(), + + errors: { + isNotFoundError: (error) => { + return error.message === 'not found exception'; + } + } + }; + } + }; +}; + describe('replaceInjectedVars uiExport', () => { it('sends xpack info if request is authenticated and license is not basic', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql({ a: 1, + telemetryOptedIn: null, xpackInitialInfo: { b: 1 } @@ -29,13 +54,14 @@ describe('replaceInjectedVars uiExport', () => { it('sends the xpack info if security plugin is disabled', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); delete server.plugins.security; const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql({ a: 1, + telemetryOptedIn: null, xpackInitialInfo: { b: 1 } @@ -44,13 +70,46 @@ describe('replaceInjectedVars uiExport', () => { it('sends the xpack info if xpack license is basic', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); + const server = mockServer(); + server.plugins.xpack_main.info.license.isOneOf.returns(true); + + const newVars = await replaceInjectedVars(originalInjectedVars, request, server); + expect(newVars).to.eql({ + a: 1, + telemetryOptedIn: null, + xpackInitialInfo: { + b: 1 + } + }); + }); + + it('respects the telemetry opt-in document when opted-out', async () => { + const originalInjectedVars = { a: 1 }; + const request = buildRequest(false); + const server = mockServer(); + server.plugins.xpack_main.info.license.isOneOf.returns(true); + + const newVars = await replaceInjectedVars(originalInjectedVars, request, server); + expect(newVars).to.eql({ + a: 1, + telemetryOptedIn: false, + xpackInitialInfo: { + b: 1 + } + }); + }); + + it('respects the telemetry opt-in document when opted-in', async () => { + const originalInjectedVars = { a: 1 }; + const request = buildRequest(true); const server = mockServer(); server.plugins.xpack_main.info.license.isOneOf.returns(true); const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql({ a: 1, + telemetryOptedIn: true, xpackInitialInfo: { b: 1 } @@ -59,7 +118,7 @@ describe('replaceInjectedVars uiExport', () => { it('sends the originalInjectedVars if not authenticated', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); server.plugins.security.isAuthenticated.returns(false); @@ -69,7 +128,7 @@ describe('replaceInjectedVars uiExport', () => { it('sends the originalInjectedVars if xpack info is unavailable', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); server.plugins.xpack_main.info.isAvailable.returns(false); @@ -79,7 +138,7 @@ describe('replaceInjectedVars uiExport', () => { it('sends the originalInjectedVars (with xpackInitialInfo = undefined) if security is disabled, xpack info is unavailable', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); delete server.plugins.security; server.plugins.xpack_main.info.isAvailable.returns(false); @@ -87,13 +146,14 @@ describe('replaceInjectedVars uiExport', () => { const newVars = await replaceInjectedVars(originalInjectedVars, request, server); expect(newVars).to.eql({ a: 1, + telemetryOptedIn: null, xpackInitialInfo: undefined }); }); it('sends the originalInjectedVars if the license check result is not available', async () => { const originalInjectedVars = { a: 1 }; - const request = {}; + const request = buildRequest(); const server = mockServer(); server.plugins.xpack_main.info.feature().getLicenseCheckResults.returns(undefined); diff --git a/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js b/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js new file mode 100644 index 0000000000000..1f9d6c5849e2f --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export async function getTelemetryOptIn(request) { + const savedObjectsClient = request.getSavedObjectsClient(); + + try { + const { attributes } = await savedObjectsClient.get('telemetry', 'telemetry'); + return attributes.enabled; + } catch (error) { + if (savedObjectsClient.errors.isNotFoundError(error)) { + return null; + } + throw error; + } +} diff --git a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js index 178795fd88ab6..990e4e1a7d53a 100644 --- a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js +++ b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getTelemetryOptIn } from "./get_telemetry_opt_in"; + export async function replaceInjectedVars(originalInjectedVars, request, server) { const xpackInfo = server.plugins.xpack_main.info; - const withXpackInfo = () => ({ + const withXpackInfo = async () => ({ ...originalInjectedVars, + telemetryOptedIn: await getTelemetryOptIn(request), xpackInitialInfo: xpackInfo.isAvailable() ? xpackInfo.toJSON() : undefined }); // security feature is disabled if (!server.plugins.security) { - return withXpackInfo(); + return await withXpackInfo(); } // not enough license info to make decision one way or another @@ -23,7 +26,7 @@ export async function replaceInjectedVars(originalInjectedVars, request, server) // authentication is not a thing you can do if (xpackInfo.license.isOneOf('basic')) { - return withXpackInfo(); + return await withXpackInfo(); } // request is not authenticated @@ -32,5 +35,5 @@ export async function replaceInjectedVars(originalInjectedVars, request, server) } // plugin enabled, license is appropriate, request is authenticated - return withXpackInfo(); + return await withXpackInfo(); } diff --git a/x-pack/plugins/xpack_main/server/routes/api/v1/telemetry/telemetry.js b/x-pack/plugins/xpack_main/server/routes/api/v1/telemetry/telemetry.js index 00f1695fa789b..9700b527698f3 100644 --- a/x-pack/plugins/xpack_main/server/routes/api/v1/telemetry/telemetry.js +++ b/x-pack/plugins/xpack_main/server/routes/api/v1/telemetry/telemetry.js @@ -17,7 +17,7 @@ import { getAllStats, getLocalStats } from '../../../../lib/telemetry'; * @param {String} end The end time of the request. * @return {Promise} An array of telemetry objects. */ -export async function getTelemetry(req, config, start, end, { _getAllStats = getAllStats, _getLocalStats = getLocalStats } = { }) { +export async function getTelemetry(req, config, start, end, { _getAllStats = getAllStats, _getLocalStats = getLocalStats } = {}) { let response = []; if (config.get('xpack.monitoring.enabled')) { @@ -26,13 +26,43 @@ export async function getTelemetry(req, config, start, end, { _getAllStats = get if (!Array.isArray(response) || response.length === 0) { // return it as an array for a consistent API response - response = [ await _getLocalStats(req) ]; + response = [await _getLocalStats(req)]; } return response; } export function telemetryRoute(server) { + /** + * Change Telemetry Opt-In preference. + */ + server.route({ + method: 'POST', + path: '/api/telemetry/v1/optIn', + config: { + validate: { + payload: Joi.object({ + enabled: Joi.bool().required() + }) + } + }, + handler: async (req, reply) => { + const savedObjectsClient = req.getSavedObjectsClient(); + try { + await savedObjectsClient.create('telemetry', { + enabled: req.payload.enabled + }, { + id: 'telemetry', + overwrite: true, + }); + } catch (err) { + return reply(wrap(err)); + } + reply({}).code(200); + } + }); + + /** * Telemetry Data * @@ -61,10 +91,10 @@ export function telemetryRoute(server) { reply(await getTelemetry(req, config, start, end)); } catch (err) { if (config.get('env.dev')) { - // don't ignore errors when running in dev mode + // don't ignore errors when running in dev mode reply(wrap(err)); } else { - // ignore errors, return empty set and a 200 + // ignore errors, return empty set and a 200 reply([]).code(200); } } diff --git a/x-pack/test/rbac_api_integration/apis/privileges/index.js b/x-pack/test/rbac_api_integration/apis/privileges/index.js index 9563a1b65540f..12ca92f3fe33c 100644 --- a/x-pack/test/rbac_api_integration/apis/privileges/index.js +++ b/x-pack/test/rbac_api_integration/apis/privileges/index.js @@ -35,6 +35,9 @@ export default function ({ getService }) { 'action:saved_objects/timelion-sheet/get', 'action:saved_objects/timelion-sheet/bulk_get', 'action:saved_objects/timelion-sheet/find', + 'action:saved_objects/telemetry/get', + 'action:saved_objects/telemetry/bulk_get', + 'action:saved_objects/telemetry/find', 'action:saved_objects/graph-workspace/get', 'action:saved_objects/graph-workspace/bulk_get', 'action:saved_objects/graph-workspace/find',