From 1512859e56d5196665ffbfaefd9b7a25d8b24100 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 29 Apr 2020 10:54:19 -0700 Subject: [PATCH 01/13] [Telemetry] oss api tests (#64602) * Adds telemetry API tests for oss * Modifies test expectations to match that within oss Co-authored-by: Elastic Machine --- test/api_integration/apis/index.js | 1 + test/api_integration/apis/telemetry/index.js | 26 ++++ test/api_integration/apis/telemetry/opt_in.ts | 123 ++++++++++++++++ .../apis/telemetry/telemetry_local.js | 133 ++++++++++++++++++ .../telemetry/telemetry_optin_notice_seen.ts | 59 ++++++++ 5 files changed, 342 insertions(+) create mode 100644 test/api_integration/apis/telemetry/index.js create mode 100644 test/api_integration/apis/telemetry/opt_in.ts create mode 100644 test/api_integration/apis/telemetry/telemetry_local.js create mode 100644 test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index c5bfc847d0041..0c4028905657d 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -33,5 +33,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./status')); loadTestFile(require.resolve('./stats')); loadTestFile(require.resolve('./ui_metric')); + loadTestFile(require.resolve('./telemetry')); }); } diff --git a/test/api_integration/apis/telemetry/index.js b/test/api_integration/apis/telemetry/index.js new file mode 100644 index 0000000000000..c79f5cb470890 --- /dev/null +++ b/test/api_integration/apis/telemetry/index.js @@ -0,0 +1,26 @@ +/* + * 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 default function({ loadTestFile }) { + describe('Telemetry', () => { + loadTestFile(require.resolve('./telemetry_local')); + loadTestFile(require.resolve('./opt_in')); + loadTestFile(require.resolve('./telemetry_optin_notice_seen')); + }); +} diff --git a/test/api_integration/apis/telemetry/opt_in.ts b/test/api_integration/apis/telemetry/opt_in.ts new file mode 100644 index 0000000000000..e4654ee3985f3 --- /dev/null +++ b/test/api_integration/apis/telemetry/opt_in.ts @@ -0,0 +1,123 @@ +/* + * 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 expect from '@kbn/expect'; + +import { TelemetrySavedObjectAttributes } from 'src/plugins/telemetry/server/telemetry_repository'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + describe('/api/telemetry/v2/optIn API', () => { + let defaultAttributes: TelemetrySavedObjectAttributes; + let kibanaVersion: any; + before(async () => { + const kibanaVersionAccessor = kibanaServer.version; + kibanaVersion = await kibanaVersionAccessor.get(); + defaultAttributes = + (await getSavedObjectAttributes(supertest).catch(err => { + if (err.message === 'expected 200 "OK", got 404 "Not Found"') { + return null; + } + throw err; + })) || {}; + + expect(typeof kibanaVersion).to.eql('string'); + expect(kibanaVersion.length).to.be.greaterThan(0); + }); + + afterEach(async () => { + await updateSavedObjectAttributes(supertest, defaultAttributes); + }); + + it('should support sending false with allowChangingOptInStatus true', async () => { + await updateSavedObjectAttributes(supertest, { + ...defaultAttributes, + allowChangingOptInStatus: true, + }); + await postTelemetryV2Optin(supertest, false, 200); + const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest); + expect(enabled).to.be(false); + expect(lastVersionChecked).to.be(kibanaVersion); + }); + + it('should support sending true with allowChangingOptInStatus true', async () => { + await updateSavedObjectAttributes(supertest, { + ...defaultAttributes, + allowChangingOptInStatus: true, + }); + await postTelemetryV2Optin(supertest, true, 200); + const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest); + expect(enabled).to.be(true); + expect(lastVersionChecked).to.be(kibanaVersion); + }); + + it('should not support sending false with allowChangingOptInStatus false', async () => { + await updateSavedObjectAttributes(supertest, { + ...defaultAttributes, + allowChangingOptInStatus: false, + }); + await postTelemetryV2Optin(supertest, false, 400); + }); + + it('should not support sending true with allowChangingOptInStatus false', async () => { + await updateSavedObjectAttributes(supertest, { + ...defaultAttributes, + allowChangingOptInStatus: false, + }); + await postTelemetryV2Optin(supertest, true, 400); + }); + + it('should not support sending null', async () => { + await postTelemetryV2Optin(supertest, null, 400); + }); + + it('should not support sending junk', async () => { + await postTelemetryV2Optin(supertest, 42, 400); + }); + }); +} + +async function postTelemetryV2Optin(supertest: any, value: any, statusCode: number): Promise { + const { body } = await supertest + .post('/api/telemetry/v2/optIn') + .set('kbn-xsrf', 'xxx') + .send({ enabled: value }) + .expect(statusCode); + + return body; +} + +async function updateSavedObjectAttributes( + supertest: any, + attributes: TelemetrySavedObjectAttributes +): Promise { + return await supertest + .post('/api/saved_objects/telemetry/telemetry') + .query({ overwrite: true }) + .set('kbn-xsrf', 'xxx') + .send({ attributes }) + .expect(200); +} + +async function getSavedObjectAttributes(supertest: any): Promise { + const { body } = await supertest.get('/api/saved_objects/telemetry/telemetry').expect(200); + return body.attributes; +} diff --git a/test/api_integration/apis/telemetry/telemetry_local.js b/test/api_integration/apis/telemetry/telemetry_local.js new file mode 100644 index 0000000000000..84bfd8a755c11 --- /dev/null +++ b/test/api_integration/apis/telemetry/telemetry_local.js @@ -0,0 +1,133 @@ +/* + * 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 expect from '@kbn/expect'; +import _ from 'lodash'; + +/* + * Create a single-level array with strings for all the paths to values in the + * source object, up to 3 deep. Going deeper than 3 causes a bit too much churn + * in the tests. + */ +function flatKeys(source) { + const recursivelyFlatKeys = (obj, path = [], depth = 0) => { + return depth < 3 && _.isObject(obj) + ? _.map(obj, (v, k) => recursivelyFlatKeys(v, [...path, k], depth + 1)) + : path.join('.'); + }; + + return _.uniq(_.flattenDeep(recursivelyFlatKeys(source))).sort((a, b) => a.localeCompare(b)); +} + +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('/api/telemetry/v2/clusters/_stats', () => { + it('should pull local stats and validate data types', async () => { + const timeRange = { + min: '2018-07-23T22:07:00Z', + max: '2018-07-23T22:13:00Z', + }; + + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.collection).to.be('local'); + expect(stats.stack_stats.kibana.count).to.be.a('number'); + expect(stats.stack_stats.kibana.indices).to.be.a('number'); + expect(stats.stack_stats.kibana.os.platforms[0].platform).to.be.a('string'); + expect(stats.stack_stats.kibana.os.platforms[0].count).to.be(1); + expect(stats.stack_stats.kibana.os.platformReleases[0].platformRelease).to.be.a('string'); + expect(stats.stack_stats.kibana.os.platformReleases[0].count).to.be(1); + expect(stats.stack_stats.kibana.plugins.telemetry.opt_in_status).to.be(false); + expect(stats.stack_stats.kibana.plugins.telemetry.usage_fetcher).to.be.a('string'); + expect(stats.stack_stats.kibana.plugins.stack_management).to.be.an('object'); + expect(stats.stack_stats.kibana.plugins.ui_metric).to.be.an('object'); + expect(stats.stack_stats.kibana.plugins.application_usage).to.be.an('object'); + expect(stats.stack_stats.kibana.plugins.kql.defaultQueryLanguage).to.be.a('string'); + expect(stats.stack_stats.kibana.plugins['tsvb-validation']).to.be.an('object'); + expect(stats.stack_stats.kibana.plugins.localization).to.be.an('object'); + expect(stats.stack_stats.kibana.plugins.csp.strict).to.be(true); + expect(stats.stack_stats.kibana.plugins.csp.warnLegacyBrowsers).to.be(true); + expect(stats.stack_stats.kibana.plugins.csp.rulesChangedFromDefault).to.be(false); + }); + + it('should pull local stats and validate fields', async () => { + const timeRange = { + min: '2018-07-23T22:07:00Z', + max: '2018-07-23T22:13:00Z', + }; + + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + const stats = body[0]; + + const actual = flatKeys(stats); + expect(actual).to.be.an('array'); + const expected = [ + 'cluster_name', + 'cluster_stats.cluster_uuid', + 'cluster_stats.indices.analysis', + 'cluster_stats.indices.completion', + 'cluster_stats.indices.count', + 'cluster_stats.indices.docs', + 'cluster_stats.indices.fielddata', + 'cluster_stats.indices.mappings', + 'cluster_stats.indices.query_cache', + 'cluster_stats.indices.segments', + 'cluster_stats.indices.shards', + 'cluster_stats.indices.store', + 'cluster_stats.nodes.count', + 'cluster_stats.nodes.discovery_types', + 'cluster_stats.nodes.fs', + 'cluster_stats.nodes.ingest', + 'cluster_stats.nodes.jvm', + 'cluster_stats.nodes.network_types', + 'cluster_stats.nodes.os', + 'cluster_stats.nodes.packaging_types', + 'cluster_stats.nodes.plugins', + 'cluster_stats.nodes.process', + 'cluster_stats.nodes.versions', + 'cluster_stats.status', + 'cluster_stats.timestamp', + 'cluster_uuid', + 'collection', + 'collectionSource', + 'stack_stats.kibana.count', + 'stack_stats.kibana.indices', + 'stack_stats.kibana.os', + 'stack_stats.kibana.plugins', + 'stack_stats.kibana.versions', + 'timestamp', + 'version', + ]; + + expect(expected.every(m => actual.includes(m))).to.be.ok(); + }); + }); +} diff --git a/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts new file mode 100644 index 0000000000000..4413c672fb46c --- /dev/null +++ b/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts @@ -0,0 +1,59 @@ +/* + * 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 expect from '@kbn/expect'; +import { Client, DeleteDocumentParams, GetParams, GetResponse } from 'elasticsearch'; +import { TelemetrySavedObjectAttributes } from 'src/plugins/telemetry/server/telemetry_repository'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const client: Client = getService('legacyEs'); + const supertest = getService('supertest'); + + describe('/api/telemetry/v2/userHasSeenNotice API Telemetry User has seen OptIn Notice', () => { + it('should update telemetry setting field via PUT', async () => { + try { + await client.delete({ + index: '.kibana', + id: 'telemetry:telemetry', + } as DeleteDocumentParams); + } catch (err) { + if (err.statusCode !== 404) { + throw err; + } + } + + await supertest + .put('/api/telemetry/v2/userHasSeenNotice') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { + _source: { telemetry }, + }: GetResponse<{ + telemetry: TelemetrySavedObjectAttributes; + }> = await client.get({ + index: '.kibana', + id: 'telemetry:telemetry', + } as GetParams); + + expect(telemetry.userHasSeenNotice).to.be(true); + }); + }); +} From 3adab851380f4a368b9aa14cb227ef9f61e16e93 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Wed, 29 Apr 2020 13:01:23 -0500 Subject: [PATCH 02/13] Minimize dependencies required by our telemetry middleware (#64665) 1. we need our redux actions for our telemetry middleware, which: 2. require types from an index file, which: 3. includes all of our model types, and everything involved in them By moving these types to a separate file and importing _that_instead, we bypass inclusion of 2 and 3 in our plugin, which equates to ~550kB (of a total of ~600kB). --- .../siem/public/lib/telemetry/middleware.ts | 2 +- x-pack/plugins/siem/public/store/model.ts | 13 +------------ .../siem/public/store/timeline/actions.ts | 2 +- x-pack/plugins/siem/public/store/types.ts | 17 +++++++++++++++++ 4 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/siem/public/store/types.ts diff --git a/x-pack/plugins/siem/public/lib/telemetry/middleware.ts b/x-pack/plugins/siem/public/lib/telemetry/middleware.ts index 59c6cb3566907..ca889e20e695f 100644 --- a/x-pack/plugins/siem/public/lib/telemetry/middleware.ts +++ b/x-pack/plugins/siem/public/lib/telemetry/middleware.ts @@ -7,7 +7,7 @@ import { Action, Dispatch, MiddlewareAPI } from 'redux'; import { track, METRIC_TYPE, TELEMETRY_EVENT } from './'; -import { timelineActions } from '../../store/actions'; +import * as timelineActions from '../../store/timeline/actions'; export const telemetryMiddleware = (api: MiddlewareAPI) => (next: Dispatch) => (action: Action) => { if (timelineActions.endTimelineSaving.match(action)) { diff --git a/x-pack/plugins/siem/public/store/model.ts b/x-pack/plugins/siem/public/store/model.ts index 9e9e663a59fe0..686dc096e61b0 100644 --- a/x-pack/plugins/siem/public/store/model.ts +++ b/x-pack/plugins/siem/public/store/model.ts @@ -9,15 +9,4 @@ export { dragAndDropModel } from './drag_and_drop'; export { hostsModel } from './hosts'; export { inputsModel } from './inputs'; export { networkModel } from './network'; - -export type KueryFilterQueryKind = 'kuery' | 'lucene'; - -export interface KueryFilterQuery { - kind: KueryFilterQueryKind; - expression: string; -} - -export interface SerializedFilterQuery { - kuery: KueryFilterQuery | null; - serializedQuery: string; -} +export * from './types'; diff --git a/x-pack/plugins/siem/public/store/timeline/actions.ts b/x-pack/plugins/siem/public/store/timeline/actions.ts index a03cc2643e014..12155decf40d4 100644 --- a/x-pack/plugins/siem/public/store/timeline/actions.ts +++ b/x-pack/plugins/siem/public/store/timeline/actions.ts @@ -12,7 +12,7 @@ import { DataProvider, QueryOperator, } from '../../components/timeline/data_providers/data_provider'; -import { KueryFilterQuery, SerializedFilterQuery } from '../model'; +import { KueryFilterQuery, SerializedFilterQuery } from '../types'; import { EventType, KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../graphql/types'; diff --git a/x-pack/plugins/siem/public/store/types.ts b/x-pack/plugins/siem/public/store/types.ts new file mode 100644 index 0000000000000..2c679ba41116e --- /dev/null +++ b/x-pack/plugins/siem/public/store/types.ts @@ -0,0 +1,17 @@ +/* + * 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 type KueryFilterQueryKind = 'kuery' | 'lucene'; + +export interface KueryFilterQuery { + kind: KueryFilterQueryKind; + expression: string; +} + +export interface SerializedFilterQuery { + kuery: KueryFilterQuery | null; + serializedQuery: string; +} From 53ff22997a1401ccbd2808831b26b92fa174de92 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Wed, 29 Apr 2020 14:06:08 -0400 Subject: [PATCH 03/13] [EPM] Update UI to handle package versions and updates (#64689) * link to installed version of detail page * add latestVersion property to EPM get package endpoint * add updates available notices * add update package button * handle various states and send installedVersion from package endpoint * fix type errors * fix install error because not returning promises * track version in state * handle unsuccessful update attempt * remove unused variable --- .../common/services/package_to_config.test.ts | 1 + .../ingest_manager/common/types/models/epm.ts | 2 + .../sections/epm/components/icons.tsx | 15 ++ .../sections/epm/components/package_card.tsx | 8 +- .../epm/hooks/use_package_install.tsx | 63 +++-- .../sections/epm/screens/detail/content.tsx | 3 +- .../epm/screens/detail/data_sources_panel.tsx | 2 +- .../sections/epm/screens/detail/header.tsx | 17 +- .../sections/epm/screens/detail/index.tsx | 4 +- .../screens/detail/installation_button.tsx | 23 +- .../epm/screens/detail/settings_panel.tsx | 236 ++++++++++++------ .../epm/screens/detail/side_nav_links.tsx | 2 +- .../server/services/epm/packages/get.ts | 4 +- .../server/services/epm/packages/index.ts | 1 + .../server/services/epm/packages/install.ts | 2 +- .../server/services/epm/packages/remove.ts | 8 +- 16 files changed, 277 insertions(+), 114 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts index cb290e61b17e5..a977a1a66e059 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -11,6 +11,7 @@ describe('Ingest Manager - packageToConfig', () => { name: 'mock-package', title: 'Mock package', version: '0.0.0', + latestVersion: '0.0.0', description: 'description', type: 'mock', categories: [], 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 05e160cdfb81a..f8779a879a049 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -204,6 +204,8 @@ export interface RegistryVarsEntry { // internal until we need them interface PackageAdditions { title: string; + latestVersion: string; + installedVersion?: string; assets: AssetsGroupedByServiceByType; } 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 new file mode 100644 index 0000000000000..64223efefaab8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icons.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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'; + +export const StyledAlert = styled(EuiIcon)` + color: ${props => props.theme.eui.euiColorWarning}; + padding: 0 5px; +`; + +export const UpdateIcon = () => ; 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 8ad081cbbabe4..ab7e87b3ad06c 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 @@ -30,9 +30,15 @@ export function PackageCard({ showInstalledBadge, status, icons, + ...restProps }: PackageCardProps) { const { toDetailView } = useLinks(); - const url = toDetailView({ name, version }); + let urlVersion = version; + // if this is an installed package, link to the version installed + if ('savedObject' in restProps) { + urlVersion = restProps.savedObject.attributes.version || version; + } + const url = toDetailView({ name, version: urlVersion }); return ( ; +type InstallPackageProps = Pick & { + fromUpdate?: boolean; +}; +type SetPackageInstallStatusProps = Pick & PackageInstallItem; function usePackageInstall({ notifications }: { notifications: NotificationsStart }) { + const { toDetailView } = useLinks(); const [packages, setPackage] = useState({}); const setPackageInstallStatus = useCallback( - ({ name, status }: { name: PackageInfo['name']; status: InstallStatus }) => { + ({ name, status, version }: SetPackageInstallStatusProps) => { + const packageProps: PackageInstallItem = { + status, + version, + }; setPackage((prev: PackagesInstall) => ({ ...prev, - [name]: { status }, + [name]: packageProps, })); }, [] ); + const getPackageInstallStatus = useCallback( + (pkg: string): PackageInstallItem => { + return packages[pkg]; + }, + [packages] + ); + const installPackage = useCallback( - async ({ name, version, title }: InstallPackageProps) => { - setPackageInstallStatus({ name, status: InstallStatus.installing }); + async ({ name, version, title, fromUpdate = false }: InstallPackageProps) => { + const currStatus = getPackageInstallStatus(name); + const newStatus = { ...currStatus, name, status: InstallStatus.installing }; + setPackageInstallStatus(newStatus); const pkgkey = `${name}-${version}`; const res = await sendInstallPackage(pkgkey); if (res.error) { - setPackageInstallStatus({ name, status: InstallStatus.notInstalled }); + if (fromUpdate) { + // if there is an error during update, set it back to the previous version + // as handling of bad update is not implemented yet + setPackageInstallStatus({ ...currStatus, name }); + } else { + setPackageInstallStatus({ name, status: InstallStatus.notInstalled, version }); + } notifications.toasts.addWarning({ title: toMountPoint( { - return packages[pkg].status; - }, - [packages] + [getPackageInstallStatus, notifications.toasts, setPackageInstallStatus, toDetailView] ); const uninstallPackage = useCallback( async ({ name, version, title }: Pick) => { - setPackageInstallStatus({ name, status: InstallStatus.uninstalling }); + setPackageInstallStatus({ name, status: InstallStatus.uninstalling, version }); const pkgkey = `${name}-${version}`; const res = await sendRemovePackage(pkgkey); if (res.error) { - setPackageInstallStatus({ name, status: InstallStatus.installed }); + setPackageInstallStatus({ name, status: InstallStatus.installed, version }); notifications.toasts.addWarning({ title: toMountPoint( ; export function ContentPanel(props: ContentPanelProps) { - const { panel, name, version, assets, title, removable } = props; + const { panel, name, version, assets, title, removable, latestVersion } = props; switch (panel) { case 'settings': return ( @@ -60,6 +60,7 @@ export function ContentPanel(props: ContentPanelProps) { assets={assets} title={title} removable={removable} + latestVersion={latestVersion} /> ); case 'data-sources': diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx index fa3245aec02c5..c82b7ed2297a7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx @@ -20,7 +20,7 @@ export const DataSourcesPanel = ({ name, version }: DataSourcesPanelProps) => { const packageInstallStatus = getPackageInstallStatus(name); // if they arrive at this page and the package is not installed, send them to overview // this happens if they arrive with a direct url or they uninstall while on this tab - if (packageInstallStatus !== InstallStatus.installed) + if (packageInstallStatus.status !== InstallStatus.installed) return ( props.theme.eui.euiSizeM}; `; -const StyledVersion = styled(Version)` - font-size: ${props => props.theme.eui.euiFontSizeS}; - color: ${props => props.theme.eui.euiColorDarkShade}; -`; - type HeaderProps = PackageInfo & { iconType?: IconType }; export function Header(props: HeaderProps) { - const { iconType, name, title, version } = props; + const { iconType, name, title, version, installedVersion, latestVersion } = props; const hasWriteCapabilites = useCapabilities().write; const { toListView } = useLinks(); const ADD_DATASOURCE_URI = useLink(`${EPM_PATH}/${name}-${version}/add-datasource`); - + const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false; return ( @@ -59,7 +54,11 @@ export function Header(props: HeaderProps) {

{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 3239d7b90e3c3..1f3eb2cc9362e 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 @@ -32,11 +32,12 @@ export function Detail() { const packageInfo = response.data?.response; const title = packageInfo?.title; const name = packageInfo?.name; + const installedVersion = packageInfo?.installedVersion; const status: InstallStatus = packageInfo?.status as any; // track install status state if (name) { - setPackageInstallStatus({ name, status }); + setPackageInstallStatus({ name, status, version: installedVersion || null }); } if (packageInfo) { setInfo({ ...packageInfo, title: title || '' }); @@ -64,7 +65,6 @@ type LayoutProps = PackageInfo & Pick & Pick diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx index cbbf1ce53c4ea..cdad67fd87548 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx @@ -13,19 +13,21 @@ import { ConfirmPackageUninstall } from './confirm_package_uninstall'; import { ConfirmPackageInstall } from './confirm_package_install'; type InstallationButtonProps = Pick & { - disabled: boolean; + disabled?: boolean; + isUpdate?: boolean; }; export function InstallationButton(props: InstallationButtonProps) { - const { assets, name, title, version, disabled = true } = props; + const { assets, name, title, version, disabled = true, isUpdate = false } = props; const hasWriteCapabilites = useCapabilities().write; const installPackage = useInstallPackage(); const uninstallPackage = useUninstallPackage(); const getPackageInstallStatus = useGetPackageInstallStatus(); - const installationStatus = getPackageInstallStatus(name); + const { status: installationStatus } = getPackageInstallStatus(name); const isInstalling = installationStatus === InstallStatus.installing; const isRemoving = installationStatus === InstallStatus.uninstalling; const isInstalled = installationStatus === InstallStatus.installed; + const showUninstallButton = isInstalled || isRemoving; const [isModalVisible, setModalVisible] = useState(false); const toggleModal = useCallback(() => { setModalVisible(!isModalVisible); @@ -36,6 +38,10 @@ export function InstallationButton(props: InstallationButtonProps) { toggleModal(); }, [installPackage, name, title, toggleModal, version]); + const handleClickUpdate = useCallback(() => { + installPackage({ name, version, title, fromUpdate: true }); + }, [installPackage, name, title, version]); + const handleClickUninstall = useCallback(() => { uninstallPackage({ name, version, title }); toggleModal(); @@ -78,6 +84,15 @@ export function InstallationButton(props: InstallationButtonProps) { ); + const updateButton = ( + + + + ); + const uninstallButton = ( - {isInstalled || isRemoving ? uninstallButton : installButton} + {isUpdate ? updateButton : showUninstallButton ? uninstallButton : installButton} {isModalVisible && (isInstalled ? uninstallModal : installModal)}
) : null; 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 3589a1a9444e1..4d4dba2a64e5a 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 @@ -8,11 +8,22 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; import { InstallStatus, PackageInfo } from '../../../../types'; import { useGetDatasources } from '../../../../hooks'; import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../constants'; import { useGetPackageInstallStatus } from '../../hooks'; import { InstallationButton } from './installation_button'; +import { UpdateIcon } from '../../components/icons'; + +const SettingsTitleCell = styled.td` + padding-right: ${props => props.theme.eui.spacerSizes.xl}; + padding-bottom: ${props => props.theme.eui.spacerSizes.m}; +`; + +const UpdatesAvailableMsgContainer = styled.span` + padding-left: ${props => props.theme.eui.spacerSizes.s}; +`; const NoteLabel = () => ( ( defaultMessage="Note:" /> ); +const UpdatesAvailableMsg = () => ( + + + + +); + export const SettingsPanel = ( - props: Pick + props: Pick ) => { + const { name, title, removable, latestVersion, version } = props; const getPackageInstallStatus = useGetPackageInstallStatus(); const { data: datasourcesData } = useGetDatasources({ perPage: 0, page: 1, kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.package.name:${props.name}`, }); - const { name, title, removable } = props; - const packageInstallStatus = getPackageInstallStatus(name); + const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name); const packageHasDatasources = !!datasourcesData?.total; + const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false; + const isViewingOldPackage = version < latestVersion; + // hide install/remove options if the user has version of the package is installed + // and this package is out of date or if they do have a version installed but it's not this one + const hideInstallOptions = + (installationStatus === InstallStatus.notInstalled && isViewingOldPackage) || + (installationStatus === InstallStatus.installed && installedVersion !== version); + + const isUpdating = installationStatus === InstallStatus.installing && installedVersion; return ( @@ -43,14 +73,13 @@ export const SettingsPanel = ( - {packageInstallStatus === InstallStatus.notInstalled || - packageInstallStatus === InstallStatus.installing ? ( + {installedVersion !== null && (

-

- -

+ + + + + + + + + + + + + + + +
+ + {installedVersion} + + {updateAvailable && } +
+ + {latestVersion} + +
+ {updateAvailable && ( +

+ +

+ )}

- ) : ( + )} + {!hideInstallOptions && !isUpdating && (
- -

+ + {installationStatus === InstallStatus.notInstalled || + installationStatus === InstallStatus.installing ? ( +
+ +

+ +

+
+ +

+ +

+
+ ) : ( +
+ +

+ +

+
+ +

+ +

+
+ )} + + +

+ +

+
+
+ {packageHasDatasources && removable === true && ( +

+ + + ), }} /> -

-
- -

- -

+

+ )} + {removable === false && ( +

+ + + + ), + }} + /> +

+ )}
)} - - -

- -

-
-
- {packageHasDatasources && removable === true && ( -

- - - - ), - }} - /> -

- )} - {removable === false && ( -

- - - - ), - }} - /> -

- )}
); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx index 05729ccfc1af4..ab168ef1530bd 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx @@ -37,7 +37,7 @@ export function SideNavLinks({ name, version, active }: NavLinkProps) { : p.theme.eui.euiFontWeightRegular}; `; // don't display Data Sources tab if the package is not installed - if (packageInstallStatus !== InstallStatus.installed && panel === 'data-sources') + if (packageInstallStatus.status !== InstallStatus.installed && panel === 'data-sources') return null; return ( 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 d76584225877c..da8d79a04b97c 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 @@ -67,9 +67,10 @@ export async function getPackageInfo(options: { pkgVersion: string; }): Promise { const { savedObjectsClient, pkgName, pkgVersion } = options; - const [item, savedObject, assets] = await Promise.all([ + const [item, savedObject, latestPackage, assets] = await Promise.all([ Registry.fetchInfo(pkgName, pkgVersion), getInstallationObject({ savedObjectsClient, pkgName }), + Registry.fetchFindLatestPackage(pkgName), Registry.getArchiveInfo(pkgName, pkgVersion), ] as const); // adding `as const` due to regression in TS 3.7.2 @@ -79,6 +80,7 @@ export async function getPackageInfo(options: { // add properties that aren't (or aren't yet) on Registry response const updated = { ...item, + latestVersion: latestPackage.version, title: item.title || nameAsTitle(item.name), assets: Registry.groupPathsByService(assets || []), }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index d49e0e661440f..c67cccd044bf5 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -43,6 +43,7 @@ export function createInstallableFrom( ? { ...from, status: InstallationStatus.installed, + installedVersion: savedObject.attributes.version, savedObject, } : { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 06f3decdbbe6f..8f51c4d78305c 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -106,7 +106,7 @@ export async function installPackage(options: { try { await deleteKibanaSavedObjectsAssets(savedObjectsClient, installedPkg.attributes.installed); } catch (err) { - // some assets may not exist if deleting during a failed update + // log these errors, some assets may not exist if deleted during a failed update } } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index ac2c869f3b9e9..befb4722b6504 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -121,8 +121,12 @@ export async function deleteKibanaSavedObjectsAssets( const deletePromises = installedObjects.map(({ id, type }) => { const assetType = type as AssetType; if (savedObjectTypes.includes(assetType)) { - savedObjectsClient.delete(assetType, id); + return savedObjectsClient.delete(assetType, id); } }); - await Promise.all(deletePromises); + try { + await Promise.all(deletePromises); + } catch (err) { + throw new Error('error deleting saved object asset'); + } } From f5a8d8e6c27958dacc16a36b24bded57b4313f9d Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Wed, 29 Apr 2020 19:05:45 +0000 Subject: [PATCH 04/13] make inserting timestamp with navigate methods optional with default true (#64655) --- test/functional/page_objects/common_page.ts | 23 +++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 862e5127bb670..93debdcc37f0a 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -44,6 +44,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo ensureCurrentUrl: boolean; shouldLoginIfPrompted: boolean; useActualUrl: boolean; + insertTimestamp: boolean; } class CommonPage { @@ -65,7 +66,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo * Logins to Kibana as default user and navigates to provided app * @param appUrl Kibana URL */ - private async loginIfPrompted(appUrl: string) { + private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { let currentUrl = await browser.getCurrentUrl(); log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); await testSubjects.find('kibanaChrome', 6 * defaultFindTimeout); // 60 sec waiting @@ -87,7 +88,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo '[data-test-subj="kibanaChrome"] nav:not(.ng-hide)', 6 * defaultFindTimeout ); - await browser.get(appUrl); + await browser.get(appUrl, insertTimestamp); currentUrl = await browser.getCurrentUrl(); log.debug(`Finished login process currentUrl = ${currentUrl}`); } @@ -95,7 +96,13 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } private async navigate(navigateProps: NavigateProps) { - const { appConfig, ensureCurrentUrl, shouldLoginIfPrompted, useActualUrl } = navigateProps; + const { + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + } = navigateProps; const appUrl = getUrl.noAuth(config.get('servers.kibana'), appConfig); await retry.try(async () => { @@ -111,7 +118,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } const currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl) + ? await this.loginIfPrompted(appUrl, insertTimestamp) : await browser.getCurrentUrl(); if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { @@ -134,6 +141,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo ensureCurrentUrl = true, shouldLoginIfPrompted = true, useActualUrl = false, + insertTimestamp = true, } = {} ) { const appConfig = { @@ -146,6 +154,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo ensureCurrentUrl, shouldLoginIfPrompted, useActualUrl, + insertTimestamp, }); } @@ -165,6 +174,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo ensureCurrentUrl = true, shouldLoginIfPrompted = true, useActualUrl = true, + insertTimestamp = true, } = {} ) { const appConfig = { @@ -178,6 +188,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo ensureCurrentUrl, shouldLoginIfPrompted, useActualUrl, + insertTimestamp, }); } @@ -208,7 +219,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo async navigateToApp( appName: string, - { basePath = '', shouldLoginIfPrompted = true, hash = '' } = {} + { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} ) { let appUrl: string; if (config.has(['apps', appName])) { @@ -239,7 +250,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo log.debug('returned from get, calling refresh'); await browser.refresh(); let currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl) + ? await this.loginIfPrompted(appUrl, insertTimestamp) : await browser.getCurrentUrl(); if (currentUrl.includes('app/kibana')) { From 02ba5fcb131dc82c4d253a8e49e40d3d9e600037 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 29 Apr 2020 12:19:31 -0700 Subject: [PATCH 05/13] [Reporting/Test] Convert functional test code to Typescript (#64601) * Convert the tests to typescript * remove outdated comment * fix typescript * fix "as any" Co-authored-by: Elastic Machine --- .../reporting/{index.js => index.ts} | 9 ++--- .../lib/{compare_pngs.js => compare_pngs.ts} | 13 ++++--- .../discover/{reporting.js => reporting.ts} | 3 +- .../visualize/{reporting.js => reporting.ts} | 3 +- x-pack/test/functional/page_objects/index.ts | 1 - .../{reporting_page.js => reporting_page.ts} | 35 ++++++++----------- 6 files changed, 33 insertions(+), 31 deletions(-) rename x-pack/test/functional/apps/dashboard/reporting/{index.js => index.ts} (94%) rename x-pack/test/functional/apps/dashboard/reporting/lib/{compare_pngs.js => compare_pngs.ts} (90%) rename x-pack/test/functional/apps/discover/{reporting.js => reporting.ts} (96%) rename x-pack/test/functional/apps/visualize/{reporting.js => reporting.ts} (94%) rename x-pack/test/functional/page_objects/{reporting_page.js => reporting_page.ts} (84%) diff --git a/x-pack/test/functional/apps/dashboard/reporting/index.js b/x-pack/test/functional/apps/dashboard/reporting/index.ts similarity index 94% rename from x-pack/test/functional/apps/dashboard/reporting/index.js rename to x-pack/test/functional/apps/dashboard/reporting/index.ts index 99be084d80d74..796e15b4e270f 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/index.js +++ b/x-pack/test/functional/apps/dashboard/reporting/index.ts @@ -5,9 +5,10 @@ */ import expect from '@kbn/expect'; -import path from 'path'; import fs from 'fs'; +import path from 'path'; import { promisify } from 'util'; +import { FtrProviderContext } from '../../../ftr_provider_context'; import { checkIfPngsMatch } from './lib/compare_pngs'; const writeFileAsync = promisify(fs.writeFile); @@ -15,7 +16,7 @@ const mkdirAsync = promisify(fs.mkdir); const REPORTS_FOLDER = path.resolve(__dirname, 'reports'); -export default function({ getService, getPageObjects }) { +export default function({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const log = getService('log'); @@ -85,14 +86,14 @@ export default function({ getService, getPageObjects }) { describe('Preserve Layout', () => { it('matches baseline report', async function() { - const writeSessionReport = async (name, rawPdf, reportExt) => { + const writeSessionReport = async (name: string, rawPdf: Buffer, reportExt: string) => { const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); await mkdirAsync(sessionDirectory, { recursive: true }); const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); await writeFileAsync(sessionReportPath, rawPdf); return sessionReportPath; }; - const getBaselineReportPath = (fileName, reportExt) => { + const getBaselineReportPath = (fileName: string, reportExt: string) => { const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`); log.debug(`getBaselineReportPath (${fullPath})`); diff --git a/x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.js b/x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.ts similarity index 90% rename from x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.js rename to x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.ts index 13c97a7fce785..b2eb645c8372c 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.js +++ b/x-pack/test/functional/apps/dashboard/reporting/lib/compare_pngs.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import path from 'path'; -import fs from 'fs'; import { promisify } from 'bluebird'; +import fs from 'fs'; +import path from 'path'; import { comparePngs } from '../../../../../../../test/functional/services/lib/compare_pngs'; -const mkdirAsync = promisify(fs.mkdir); +const mkdirAsync = promisify(fs.mkdir); -export async function checkIfPngsMatch(actualpngPath, baselinepngPath, screenshotsDirectory, log) { +export async function checkIfPngsMatch( + actualpngPath: string, + baselinepngPath: string, + screenshotsDirectory: string, + log: any +) { log.debug(`checkIfpngsMatch: ${actualpngPath} vs ${baselinepngPath}`); // Copy the pngs into the screenshot session directory, as that's where the generated pngs will automatically be // stored. diff --git a/x-pack/test/functional/apps/discover/reporting.js b/x-pack/test/functional/apps/discover/reporting.ts similarity index 96% rename from x-pack/test/functional/apps/discover/reporting.js rename to x-pack/test/functional/apps/discover/reporting.ts index 4aa005fc2db55..7a33e7f5135d4 100644 --- a/x-pack/test/functional/apps/discover/reporting.js +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -5,8 +5,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function({ getService, getPageObjects }) { +export default function({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const esArchiver = getService('esArchiver'); const browser = getService('browser'); diff --git a/x-pack/test/functional/apps/visualize/reporting.js b/x-pack/test/functional/apps/visualize/reporting.ts similarity index 94% rename from x-pack/test/functional/apps/visualize/reporting.js rename to x-pack/test/functional/apps/visualize/reporting.ts index bc252e1ad4134..5ef954e334d81 100644 --- a/x-pack/test/functional/apps/visualize/reporting.js +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -5,8 +5,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function({ getService, getPageObjects }) { +export default function({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const log = getService('log'); diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 782d57adea770..4b8c2944ef190 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -19,7 +19,6 @@ import { GraphPageProvider } from './graph_page'; import { GrokDebuggerPageProvider } from './grok_debugger_page'; // @ts-ignore not ts yet import { WatcherPageProvider } from './watcher_page'; -// @ts-ignore not ts yet import { ReportingPageProvider } from './reporting_page'; // @ts-ignore not ts yet import { AccountSettingProvider } from './accountsetting_page'; diff --git a/x-pack/test/functional/page_objects/reporting_page.js b/x-pack/test/functional/page_objects/reporting_page.ts similarity index 84% rename from x-pack/test/functional/page_objects/reporting_page.js rename to x-pack/test/functional/page_objects/reporting_page.ts index b24ba8cf95d1c..2c20519a8d214 100644 --- a/x-pack/test/functional/page_objects/reporting_page.js +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -4,25 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import http, { IncomingMessage } from 'http'; +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; import { parse } from 'url'; -import http from 'http'; -/* - * NOTE: Reporting is a service, not an app. The page objects that are - * important for generating reports belong to the apps that integrate with the - * Reporting service. Eventually, this file should be dissolved across the - * apps that need it for testing their integration. - * Issue: https://github.com/elastic/kibana/issues/52927 - */ -export function ReportingPageProvider({ getService, getPageObjects }) { +export function ReportingPageProvider({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const log = getService('log'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); - const PageObjects = getPageObjects(['common', 'security', 'share', 'timePicker']); + const PageObjects = getPageObjects(['common', 'security' as any, 'share', 'timePicker']); // FIXME: Security PageObject is not Typescript class ReportingPage { - async forceSharedItemsContainerSize({ width }) { + async forceSharedItemsContainerSize({ width }: { width: number }) { await browser.execute(` var el = document.querySelector('[data-shared-items-container]'); el.style.flex="none"; @@ -30,7 +24,7 @@ export function ReportingPageProvider({ getService, getPageObjects }) { `); } - async getReportURL(timeout) { + async getReportURL(timeout: number) { log.debug('getReportURL'); const url = await testSubjects.getAttribute('downloadCompletedReportButton', 'href', timeout); @@ -48,7 +42,7 @@ export function ReportingPageProvider({ getService, getPageObjects }) { `); } - getResponse(url) { + getResponse(url: string): Promise { log.debug(`getResponse for ${url}`); const auth = 'test_user:changeme'; // FIXME not sure why there is no config that can be read for this const headers = { @@ -62,29 +56,30 @@ export function ReportingPageProvider({ getService, getPageObjects }) { hostname: parsedUrl.hostname, path: parsedUrl.path, port: parsedUrl.port, - responseType: 'arraybuffer', headers, }, - res => { + (res: IncomingMessage) => { resolve(res); } ) - .on('error', e => { + .on('error', (e: Error) => { log.error(e); reject(e); }); }); } - async getRawPdfReportData(url) { - const data = []; // List of Buffer objects + async getRawPdfReportData(url: string): Promise { + const data: Buffer[] = []; // List of Buffer objects log.debug(`getRawPdfReportData for ${url}`); return new Promise(async (resolve, reject) => { const response = await this.getResponse(url).catch(reject); - response.on('data', chunk => data.push(chunk)); - response.on('end', () => resolve(Buffer.concat(data))); + if (response) { + response.on('data', (chunk: Buffer) => data.push(chunk)); + response.on('end', () => resolve(Buffer.concat(data))); + } }); } From d6f0c785d916660b9347f25bb6e3034989f8432a Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 29 Apr 2020 13:27:55 -0600 Subject: [PATCH 06/13] [Maps] do not display EMS or kibana layer wizards when not configured (#64554) * [Maps] do not display EMS or kibana layer wizards when not configured * default tilemap to empty object instead of array * fix typo Co-authored-by: Elastic Machine --- x-pack/legacy/plugins/maps/index.js | 2 +- .../plugins/maps/public/layers/layer_wizard_registry.ts | 5 ++++- .../ems_file_source/ems_boundaries_layer_wizard.tsx | 5 +++++ .../sources/ems_tms_source/ems_base_map_layer_wizard.tsx | 5 +++++ .../kibana_regionmap_layer_wizard.tsx | 6 ++++++ .../kibana_base_map_layer_wizard.tsx | 6 ++++++ x-pack/plugins/maps/public/meta.js | 9 ++++++--- 7 files changed, 33 insertions(+), 5 deletions(-) diff --git a/x-pack/legacy/plugins/maps/index.js b/x-pack/legacy/plugins/maps/index.js index d1e8892fa2c98..a1186e04ee27a 100644 --- a/x-pack/legacy/plugins/maps/index.js +++ b/x-pack/legacy/plugins/maps/index.js @@ -54,7 +54,7 @@ export function maps(kibana) { emsLandingPageUrl: mapConfig.emsLandingPageUrl, kbnPkgVersion: serverConfig.get('pkg.version'), regionmapLayers: _.get(mapConfig, 'regionmap.layers', []), - tilemap: _.get(mapConfig, 'tilemap', []), + tilemap: _.get(mapConfig, 'tilemap', {}), }; }, styleSheetPaths: `${__dirname}/public/index.scss`, diff --git a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts index 633e8c86d8c94..7715541b1c52d 100644 --- a/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/layers/layer_wizard_registry.ts @@ -20,6 +20,7 @@ export type RenderWizardArguments = { }; export type LayerWizard = { + checkVisibility?: () => boolean; description: string; icon: string; isIndexingSource?: boolean; @@ -34,5 +35,7 @@ export function registerLayerWizard(layerWizard: LayerWizard) { } export function getLayerWizards(): LayerWizard[] { - return [...registry]; + return registry.filter(layerWizard => { + return layerWizard.checkVisibility ? layerWizard.checkVisibility() : true; + }); } diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index f31e770df2d95..a6e2e7f42657c 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -12,8 +12,13 @@ import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry' import { EMSFileCreateSourceEditor } from './create_source_editor'; // @ts-ignore import { EMSFileSource, sourceTitle } from './ems_file_source'; +// @ts-ignore +import { isEmsEnabled } from '../../../meta'; export const emsBoundariesLayerWizardConfig: LayerWizard = { + checkVisibility: () => { + return isEmsEnabled(); + }, description: i18n.translate('xpack.maps.source.emsFileDescription', { defaultMessage: 'Administrative boundaries from Elastic Maps Service', }), diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index ced33a0bcf84a..fc745edbabee8 100644 --- a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -12,8 +12,13 @@ import { EMSTMSSource, sourceTitle } from './ems_tms_source'; import { VectorTileLayer } from '../../vector_tile_layer'; // @ts-ignore import { TileServiceSelect } from './tile_service_select'; +// @ts-ignore +import { isEmsEnabled } from '../../../meta'; export const emsBaseMapLayerWizardConfig: LayerWizard = { + checkVisibility: () => { + return isEmsEnabled(); + }, description: i18n.translate('xpack.maps.source.emsTileDescription', { defaultMessage: 'Tile map service from Elastic Maps Service', }), diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx index 4321501760faf..a9adec2bda2c8 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx @@ -12,8 +12,14 @@ import { KibanaRegionmapSource, sourceTitle } from './kibana_regionmap_source'; import { VectorLayer } from '../../vector_layer'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; +// @ts-ignore +import { getKibanaRegionList } from '../../../meta'; export const kibanaRegionMapLayerWizardConfig: LayerWizard = { + checkVisibility: () => { + const regions = getKibanaRegionList(); + return regions.length; + }, description: i18n.translate('xpack.maps.source.kbnRegionMapDescription', { defaultMessage: 'Vector data from hosted GeoJSON configured in kibana.yml', }), diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index aeea2d6084f84..141fabeedd3e5 100644 --- a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -12,8 +12,14 @@ import { CreateSourceEditor } from './create_source_editor'; // @ts-ignore import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; import { TileLayer } from '../../tile_layer'; +// @ts-ignore +import { getKibanaTileMap } from '../../../meta'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { + checkVisibility: () => { + const tilemap = getKibanaTileMap(); + return !!tilemap.url; + }, description: i18n.translate('xpack.maps.source.kbnTMSDescription', { defaultMessage: 'Tile map service configured in kibana.yml', }), diff --git a/x-pack/plugins/maps/public/meta.js b/x-pack/plugins/maps/public/meta.js index d4612554cf00b..c3245e8e98db2 100644 --- a/x-pack/plugins/maps/public/meta.js +++ b/x-pack/plugins/maps/public/meta.js @@ -36,12 +36,15 @@ function fetchFunction(...args) { return fetch(...args); } +export function isEmsEnabled() { + return getInjectedVarFunc()('isEmsEnabled', true); +} + let emsClient = null; let latestLicenseId = null; export function getEMSClient() { if (!emsClient) { - const isEmsEnabled = getInjectedVarFunc()('isEmsEnabled', true); - if (isEmsEnabled) { + if (isEmsEnabled()) { const proxyElasticMapsServiceInMaps = getInjectedVarFunc()( 'proxyElasticMapsServiceInMaps', false @@ -86,7 +89,7 @@ export function getEMSClient() { } export function getGlyphUrl() { - if (!getInjectedVarFunc()('isEmsEnabled', true)) { + if (!isEmsEnabled()) { return ''; } return getInjectedVarFunc()('proxyElasticMapsServiceInMaps', false) From 2e410d8952324efdc43dd60e5470d4db3bd5257f Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 29 Apr 2020 12:53:06 -0700 Subject: [PATCH 07/13] skip flaky suite (#64812) (#64723) --- .../test_suites/event_log/public_api_integration.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index e5b840b335846..d7bbc29bd861e 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -19,7 +19,9 @@ export default function({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); - describe('Event Log public API', () => { + // FLAKY: https://github.com/elastic/kibana/issues/64723 + // FLAKY: https://github.com/elastic/kibana/issues/64812 + describe.skip('Event Log public API', () => { it('should allow querying for events by Saved Object', async () => { const id = uuid.v4(); From 1669a1044108a11b44b4ee998d55beb84e4e942f Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 29 Apr 2020 15:36:37 -0500 Subject: [PATCH 08/13] [Metrics UI] Fix alerting when a filter query is present (#64575) --- .../metric_threshold_executor.ts | 23 ++++++++++++------- .../apis/infra/metrics_alerting.ts | 18 +++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 946f1c14bf593..cf691f73bdc2c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -51,17 +51,19 @@ const getCurrentValueFromAggregations = ( const getParsedFilterQuery: ( filterQuery: string | undefined -) => Record = filterQuery => { +) => Record | Array> = filterQuery => { if (!filterQuery) return {}; try { return JSON.parse(filterQuery).bool; } catch (e) { - return { - query_string: { - query: filterQuery, - analyze_wildcard: true, + return [ + { + query_string: { + query: filterQuery, + analyze_wildcard: true, + }, }, - }; + ]; } }; @@ -159,8 +161,12 @@ export const getElasticsearchMetricQuery = ( return { query: { bool: { - filter: [...rangeFilters, ...metricFieldFilters], - ...parsedFilterQuery, + filter: [ + ...rangeFilters, + ...metricFieldFilters, + ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []), + ], + ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}), }, }, size: 0, @@ -233,6 +239,7 @@ const getMetric: ( body: searchBody, index, }); + return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) }; } catch (e) { return { '*': undefined }; // Trigger an Error state diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts index 4f17f9db67483..19879f5761ab2 100644 --- a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -39,6 +39,7 @@ export default function({ getService }: FtrProviderContext) { }); expect(result.error).to.not.be.ok(); expect(result.hits).to.be.ok(); + expect(result.aggregations).to.be.ok(); }); } it('should work with a filterQuery', async () => { @@ -53,6 +54,21 @@ export default function({ getService }: FtrProviderContext) { }); expect(result.error).to.not.be.ok(); expect(result.hits).to.be.ok(); + expect(result.aggregations).to.be.ok(); + }); + it('should work with a filterQuery in KQL format', async () => { + const searchBody = getElasticsearchMetricQuery( + getSearchParams('avg'), + undefined, + '"agent.hostname":"foo"' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + expect(result.aggregations).to.be.ok(); }); }); describe('querying with a groupBy parameter', () => { @@ -65,6 +81,7 @@ export default function({ getService }: FtrProviderContext) { }); expect(result.error).to.not.be.ok(); expect(result.hits).to.be.ok(); + expect(result.aggregations).to.be.ok(); }); } it('should work with a filterQuery', async () => { @@ -79,6 +96,7 @@ export default function({ getService }: FtrProviderContext) { }); expect(result.error).to.not.be.ok(); expect(result.hits).to.be.ok(); + expect(result.aggregations).to.be.ok(); }); }); }); From 6338cef317f023e3cfaebcc27a0c0fb1e9b857e8 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 29 Apr 2020 16:41:59 -0400 Subject: [PATCH 09/13] [Ingest] Allow to enable monitoring of elastic agent (#63598) --- .../common/constants/agent_config.ts | 1 + .../common/types/models/agent_config.ts | 9 ++ .../agent_config/components/config_form.tsx | 65 ++++++++- .../list_page/components/create_config.tsx | 1 + .../ingest_manager/server/saved_objects.ts | 1 + .../server/services/agent_config.test.ts | 134 ++++++++++++++++++ .../server/services/agent_config.ts | 45 ++++-- .../server/types/models/agent_config.ts | 3 + 8 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/server/services/agent_config.test.ts diff --git a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts index c5067480fb953..9bc1293799d3c 100644 --- a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts @@ -14,6 +14,7 @@ export const DEFAULT_AGENT_CONFIG = { status: AgentConfigStatus.Active, datasources: [], is_default: true, + monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, }; export const DEFAULT_AGENT_CONFIGS_PACKAGES = [DefaultPackages.system]; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index 2372caee512af..7705956590c16 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -23,6 +23,7 @@ export interface NewAgentConfig { namespace?: string; description?: string; is_default?: boolean; + monitoring_enabled?: Array<'logs' | 'metrics'>; } export interface AgentConfig extends NewAgentConfig, SavedObjectAttributes { @@ -60,4 +61,12 @@ export interface FullAgentConfig { }; datasources: FullAgentConfigDatasource[]; revision?: number; + settings?: { + monitoring: { + use_output?: string; + enabled: boolean; + metrics: boolean; + logs: boolean; + }; + }; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx index 0d53ca34a1fef..92c44d86e47c6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx @@ -18,6 +18,7 @@ import { EuiText, EuiComboBox, EuiIconTip, + EuiCheckboxGroup, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -30,7 +31,7 @@ interface ValidationResults { const StyledEuiAccordion = styled(EuiAccordion)` .ingest-active-button { - color: ${props => props.theme.eui.euiColorPrimary}}; + color: ${props => props.theme.eui.euiColorPrimary}; } `; @@ -244,6 +245,68 @@ export const AgentConfigForm: React.FunctionComponent = ({ )} + + + + +

+ +

+
+ + + + +
+ + { + acc[key] = true; + return acc; + }, + { logs: false, metrics: false } + )} + onChange={id => { + if (id !== 'logs' && id !== 'metrics') { + return; + } + + const hasLogs = + agentConfig.monitoring_enabled && agentConfig.monitoring_enabled.indexOf(id) >= 0; + + const previousValues = agentConfig.monitoring_enabled || []; + updateAgentConfig({ + monitoring_enabled: hasLogs + ? previousValues.filter(type => type !== id) + : [...previousValues, id], + }); + }} + /> + +
); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index 1fe116ef36090..9f582e7e2fbe6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -34,6 +34,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos description: '', namespace: '', is_default: undefined, + monitoring_enabled: ['logs', 'metrics'], }); const [isLoading, setIsLoading] = useState(false); const [withSysMonitoring, setWithSysMonitoring] = useState(true); diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts index 882258e859555..90fe68e61bb1b 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts @@ -128,6 +128,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { updated_on: { type: 'keyword' }, updated_by: { type: 'keyword' }, revision: { type: 'integer' }, + monitoring_enabled: { type: 'keyword' }, }, }, }, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts new file mode 100644 index 0000000000000..17758f6e3d7f1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { agentConfigService } from './agent_config'; +import { Output } from '../types'; + +function getSavedObjectMock(configAttributes: any) { + const mock = savedObjectsClientMock.create(); + + mock.get.mockImplementation(async (type: string, id: string) => { + return { + type, + id, + references: [], + attributes: configAttributes, + }; + }); + + return mock; +} + +jest.mock('./output', () => { + return { + outputService: { + getDefaultOutputId: () => 'test-id', + get: (): Output => { + return { + id: 'test-id', + is_default: true, + name: 'default', + // @ts-ignore + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + }; + }, + }, + }; +}); + +describe('agent config', () => { + describe('getFullConfig', () => { + it('should return a config without monitoring if not monitoring is not enabled', async () => { + const soClient = getSavedObjectMock({ + revision: 1, + }); + const config = await agentConfigService.getFullConfig(soClient, 'config'); + + expect(config).toMatchObject({ + id: 'config', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + datasources: [], + revision: 1, + settings: { + monitoring: { + enabled: false, + logs: false, + metrics: false, + }, + }, + }); + }); + + it('should return a config with monitoring if monitoring is enabled for logs', async () => { + const soClient = getSavedObjectMock({ + revision: 1, + monitoring_enabled: ['logs'], + }); + const config = await agentConfigService.getFullConfig(soClient, 'config'); + + expect(config).toMatchObject({ + id: 'config', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + datasources: [], + revision: 1, + settings: { + monitoring: { + use_output: 'default', + enabled: true, + logs: true, + metrics: false, + }, + }, + }); + }); + + it('should return a config with monitoring if monitoring is enabled for metrics', async () => { + const soClient = getSavedObjectMock({ + revision: 1, + monitoring_enabled: ['metrics'], + }); + const config = await agentConfigService.getFullConfig(soClient, 'config'); + + expect(config).toMatchObject({ + id: 'config', + outputs: { + default: { + type: 'elasticsearch', + hosts: ['http://127.0.0.1:9201'], + ca_sha256: undefined, + api_key: undefined, + }, + }, + datasources: [], + revision: 1, + settings: { + monitoring: { + use_output: 'default', + enabled: true, + logs: false, + metrics: true, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 75bbfc21293c2..7ab6ef1920c18 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -301,28 +301,49 @@ class AgentConfigService { if (!config) { return null; } + const defaultOutput = await outputService.get( + soClient, + await outputService.getDefaultOutputId(soClient) + ); const agentConfig: FullAgentConfig = { id: config.id, outputs: { // TEMPORARY as we only support a default output - ...[ - await outputService.get(soClient, await outputService.getDefaultOutputId(soClient)), - ].reduce((outputs, { config: outputConfig, name, type, hosts, ca_sha256, api_key }) => { - outputs[name] = { - type, - hosts, - ca_sha256, - api_key, - ...outputConfig, - }; - return outputs; - }, {} as FullAgentConfig['outputs']), + ...[defaultOutput].reduce( + (outputs, { config: outputConfig, name, type, hosts, ca_sha256, api_key }) => { + outputs[name] = { + type, + hosts, + ca_sha256, + api_key, + ...outputConfig, + }; + return outputs; + }, + {} as FullAgentConfig['outputs'] + ), }, datasources: (config.datasources as Datasource[]) .filter(datasource => datasource.enabled) .map(ds => storedDatasourceToAgentDatasource(ds)), revision: config.revision, + ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 + ? { + settings: { + monitoring: { + use_output: defaultOutput.name, + enabled: true, + logs: config.monitoring_enabled.indexOf('logs') >= 0, + metrics: config.monitoring_enabled.indexOf('metrics') >= 0, + }, + }, + } + : { + settings: { + monitoring: { enabled: false, logs: false, metrics: false }, + }, + }), }; return agentConfig; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts index 040b2eb16289a..59cadf3bd7f74 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts @@ -11,6 +11,9 @@ const AgentConfigBaseSchema = { name: schema.string(), namespace: schema.maybe(schema.string()), description: schema.maybe(schema.string()), + monitoring_enabled: schema.maybe( + schema.arrayOf(schema.oneOf([schema.literal('logs'), schema.literal('metrics')])) + ), }; export const NewAgentConfigSchema = schema.object({ From 110648258c6635e3bc936c389e06533b00960981 Mon Sep 17 00:00:00 2001 From: Henry Harding Date: Wed, 29 Apr 2020 17:18:51 -0400 Subject: [PATCH 10/13] Improve alpha messaging (#64692) * use fixed table layout * add alpha messaging flyout * Added alpha badge + data streams link * Update x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx Co-Authored-By: Jen Huang * Update x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx Co-Authored-By: Jen Huang * Update x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx Co-Authored-By: Jen Huang * Update x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx Co-Authored-By: Jen Huang * remove small tags * change messaging from alpha to experimental * add period * remove unused imports * fixed i18n ids Co-authored-by: Jen Huang Co-authored-by: Elastic Machine --- .../components/alpha_flyout.tsx | 101 ++++++++++++++++++ .../components/alpha_messaging.tsx | 50 +++++---- .../sections/overview/index.tsx | 24 ++++- 3 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx new file mode 100644 index 0000000000000..1e7a14e350229 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx @@ -0,0 +1,101 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiLink, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + onClose: () => void; +} + +export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { + return ( + + + +

+ +

+
+
+ + +

+ +

+ + + + ), + forumLink: ( + + + + ), + }} + /> +

+ +

+ + + + ), + }} + /> +

+
+
+ + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx index 0f3ddee29fa44..5a06a9a879441 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx @@ -3,35 +3,45 @@ * 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 React, { useState } from 'react'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiText } from '@elastic/eui'; +import { EuiText, EuiLink } from '@elastic/eui'; +import { AlphaFlyout } from './alpha_flyout'; const Message = styled(EuiText).attrs(props => ({ color: 'subdued', textAlign: 'center', + size: 's', }))` padding: ${props => props.theme.eui.paddingSizes.m}; `; -export const AlphaMessaging: React.FC<{}> = () => ( - -

- - +export const AlphaMessaging: React.FC<{}> = () => { + const [isAlphaFlyoutOpen, setIsAlphaFlyoutOpen] = useState(false); + + return ( + <> + +

+ + + + {' – '} - - {' – '} - - -

-
-); + />{' '} + setIsAlphaFlyoutOpen(true)}> + View more details. + +

+ + {isAlphaFlyoutOpen && setIsAlphaFlyoutOpen(false)} />} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx index 05d150fd9ae23..70d8e7d6882f8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components'; import { EuiButton, EuiButtonEmpty, + EuiBetaBadge, EuiPanel, EuiText, EuiTitle, @@ -19,10 +20,11 @@ import { EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { WithHeaderLayout } from '../../layouts'; import { useLink, useGetAgentConfigs } from '../../hooks'; import { AgentEnrollmentFlyout } from '../fleet/agent_list_page/components'; -import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from '../../constants'; +import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH, DATA_STREAM_PATH } from '../../constants'; const OverviewPanel = styled(EuiPanel).attrs(props => ({ paddingSize: 'm', @@ -57,6 +59,11 @@ const OverviewStats = styled(EuiDescriptionList).attrs(props => ({ } `; +const AlphaBadge = styled(EuiBetaBadge)` + vertical-align: top; + margin-left: ${props => props.theme.eui.paddingSizes.s}; +`; + export const IngestManagerOverview: React.FunctionComponent = () => { // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); @@ -79,6 +86,19 @@ export const IngestManagerOverview: React.FunctionComponent = () => { id="xpack.ingestManager.overviewPageTitle" defaultMessage="Ingest Manager" /> + @@ -213,7 +233,7 @@ export const IngestManagerOverview: React.FunctionComponent = () => { /> - + Date: Wed, 29 Apr 2020 19:14:47 -0400 Subject: [PATCH 11/13] [Lens] Use suggestion system in chart switcher for subtypes (#64613) Co-authored-by: Elastic Machine --- .../datatable_visualization/visualization.tsx | 4 ++ .../config_panel/chart_switch.test.tsx | 33 +++++++++--- .../config_panel/chart_switch.tsx | 25 ++++++--- .../editor_frame/suggestion_helpers.ts | 9 +++- .../public/editor_frame_service/mocks.tsx | 1 + .../metric_visualization.tsx | 4 ++ x-pack/plugins/lens/public/types.ts | 9 ++++ .../xy_visualization/xy_visualization.test.ts | 41 +++++++++++++- .../xy_visualization/xy_visualization.tsx | 53 ++++++++++++------- 9 files changed, 143 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 359c06a6a9ebc..48729448b2ea5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -41,6 +41,10 @@ export const datatableVisualization: Visualization< }, ], + getVisualizationTypeId() { + return 'lnsDatatable'; + }, + getLayerIds(state) { return state.layers.map(l => l.layerId); }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx index 3c61d270b1bcf..c8d8064e60e38 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx @@ -62,7 +62,25 @@ describe('chart_switch', () => { id: 'subvisC2', label: 'C2', }, + { + icon: 'empty', + id: 'subvisC3', + label: 'C3', + }, ], + getSuggestions: jest.fn(options => { + if (options.subVisualizationId === 'subvisC2') { + return []; + } + return [ + { + score: 1, + title: '', + state: `suggestion`, + previewIcon: 'empty', + }, + ]; + }), }, }; } @@ -313,10 +331,11 @@ describe('chart_switch', () => { expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined(); }); - it('should not indicate data loss if visualization is not changed', () => { + it('should not show a warning when the subvisualization is the same', () => { const dispatch = jest.fn(); const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); + visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); const switchVisualizationType = jest.fn(() => 'therebedragons'); visualizations.visC.switchVisualizationType = switchVisualizationType; @@ -333,10 +352,10 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined(); + expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).not.toBeDefined(); }); - it('should remove all layers if there is no suggestion', () => { + it('should get suggestions when switching subvisualization', () => { const dispatch = jest.fn(); const visualizations = mockVisualizations(); visualizations.visB.getSuggestions.mockReturnValueOnce([]); @@ -377,7 +396,7 @@ describe('chart_switch', () => { const dispatch = jest.fn(); const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); - const switchVisualizationType = jest.fn(() => 'therebedragons'); + const switchVisualizationType = jest.fn(() => 'switched'); visualizations.visC.switchVisualizationType = switchVisualizationType; @@ -393,12 +412,12 @@ describe('chart_switch', () => { /> ); - switchTo('subvisC2', component); - expect(switchVisualizationType).toHaveBeenCalledWith('subvisC2', 'therebegriffins'); + switchTo('subvisC3', component); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', 'suggestion'); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'SWITCH_VISUALIZATION', - initialState: 'therebedragons', + initialState: 'switched', }) ); expect(frame.removeLayers).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx index 1461449f3c1c8..d73f83e75c0e4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx @@ -105,7 +105,16 @@ export function ChartSwitch(props: Props) { const switchVisType = props.visualizationMap[visualizationId].switchVisualizationType || ((_type: string, initialState: unknown) => initialState); - if (props.visualizationId === visualizationId) { + const layers = Object.entries(props.framePublicAPI.datasourceLayers); + const containsData = layers.some( + ([_layerId, datasource]) => datasource.getTableSpec().length > 0 + ); + // Always show the active visualization as a valid selection + if ( + props.visualizationId === visualizationId && + props.visualizationState && + newVisualization.getVisualizationTypeId(props.visualizationState) === subVisualizationId + ) { return { visualizationId, subVisualizationId, @@ -116,13 +125,13 @@ export function ChartSwitch(props: Props) { }; } - const layers = Object.entries(props.framePublicAPI.datasourceLayers); - const containsData = layers.some( - ([_layerId, datasource]) => datasource.getTableSpec().length > 0 + const topSuggestion = getTopSuggestion( + props, + visualizationId, + newVisualization, + subVisualizationId ); - const topSuggestion = getTopSuggestion(props, visualizationId, newVisualization); - let dataLoss: VisualizationSelection['dataLoss']; if (!containsData) { @@ -250,7 +259,8 @@ export function ChartSwitch(props: Props) { function getTopSuggestion( props: Props, visualizationId: string, - newVisualization: Visualization + newVisualization: Visualization, + subVisualizationId?: string ): Suggestion | undefined { const suggestions = getSuggestions({ datasourceMap: props.datasourceMap, @@ -258,6 +268,7 @@ function getTopSuggestion( visualizationMap: { [visualizationId]: newVisualization }, activeVisualizationId: props.visualizationId, visualizationState: props.visualizationState, + subVisualizationId, }).filter(suggestion => { // don't use extended versions of current data table on switching between visualizations // to avoid confusing the user. diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index eabcdfa7a24ab..949ae1f43448e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -44,6 +44,7 @@ export function getSuggestions({ datasourceStates, visualizationMap, activeVisualizationId, + subVisualizationId, visualizationState, field, }: { @@ -57,6 +58,7 @@ export function getSuggestions({ >; visualizationMap: Record; activeVisualizationId: string | null; + subVisualizationId?: string; visualizationState: unknown; field?: unknown; }): Suggestion[] { @@ -89,7 +91,8 @@ export function getSuggestions({ table, visualizationId, datasourceSuggestion, - currentVisualizationState + currentVisualizationState, + subVisualizationId ); }) ) @@ -108,13 +111,15 @@ function getVisualizationSuggestions( table: TableSuggestion, visualizationId: string, datasourceSuggestion: DatasourceSuggestion & { datasourceId: string }, - currentVisualizationState: unknown + currentVisualizationState: unknown, + subVisualizationId?: string ) { return visualization .getSuggestions({ table, state: currentVisualizationState, keptLayerIds: datasourceSuggestion.keptLayerIds, + subVisualizationId, }) .map(({ state, ...visualizationSuggestion }) => ({ ...visualizationSuggestion, diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 50cd1ad8bd53a..e684fe8b3b5d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -28,6 +28,7 @@ export function createMockVisualization(): jest.Mocked { label: 'TEST', }, ], + getVisualizationTypeId: jest.fn(_state => 'empty'), getDescription: jest.fn(_state => ({ label: '' })), switchVisualizationType: jest.fn((_, x) => x), getPersistableState: jest.fn(_state => _state), diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx index 73b8019a31eaa..04a1c3865f22d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -53,6 +53,10 @@ export const metricVisualization: Visualization = { }, ], + getVisualizationTypeId() { + return 'lnsMetric'; + }, + clearLayer(state) { return { ...state, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 181f192520d0d..ed0af8545f012 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -312,6 +312,10 @@ export interface SuggestionRequest { * The visualization needs to know which table is being suggested */ keptLayerIds: string[]; + /** + * Different suggestions can be generated for each subtype of the visualization + */ + subVisualizationId?: string; } /** @@ -388,6 +392,11 @@ export interface Visualization { * but can register multiple subtypes */ visualizationTypes: VisualizationType[]; + /** + * Return the ID of the current visualization. Used to highlight + * the active subtype of the visualization. + */ + getVisualizationTypeId: (state: T) => string; /** * If the visualization has subtypes, update the subtype in state. */ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts index beccf0dc46eb4..d176905c65120 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.test.ts @@ -27,7 +27,7 @@ function exampleState(): State { } describe('xy_visualization', () => { - describe('getDescription', () => { + describe('#getDescription', () => { function mixedState(...types: SeriesType[]) { const state = exampleState(); return { @@ -81,6 +81,45 @@ describe('xy_visualization', () => { }); }); + describe('#getVisualizationTypeId', () => { + function mixedState(...types: SeriesType[]) { + const state = exampleState(); + return { + ...state, + layers: types.map((t, i) => ({ + ...state.layers[0], + layerId: `layer_${i}`, + seriesType: t, + })), + }; + } + + it('should show mixed when each layer is different', () => { + expect(xyVisualization.getVisualizationTypeId(mixedState('bar', 'line'))).toEqual('mixed'); + }); + + it('should show the preferredSeriesType if there are no layers', () => { + expect(xyVisualization.getVisualizationTypeId(mixedState())).toEqual('bar'); + }); + + it('should combine multiple layers into one type', () => { + expect( + xyVisualization.getVisualizationTypeId(mixedState('bar_horizontal', 'bar_horizontal')) + ).toEqual('bar_horizontal'); + }); + + it('should return the subtype for single layers', () => { + expect(xyVisualization.getVisualizationTypeId(mixedState('area'))).toEqual('area'); + expect(xyVisualization.getVisualizationTypeId(mixedState('line'))).toEqual('line'); + expect(xyVisualization.getVisualizationTypeId(mixedState('area_stacked'))).toEqual( + 'area_stacked' + ); + expect(xyVisualization.getVisualizationTypeId(mixedState('bar_horizontal_stacked'))).toEqual( + 'bar_horizontal_stacked' + ); + }); + }); + describe('#initialize', () => { it('loads default state', () => { const mockFrame = createMockFramePublicAPI(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx index c72fa0fec24d7..e91edf9cc0183 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -12,7 +12,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu } from './xy_config_panel'; -import { Visualization, OperationMetadata } from '../types'; +import { Visualization, OperationMetadata, VisualizationType } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; import { toExpression, toPreviewExpression } from './to_expression'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; @@ -24,6 +24,18 @@ const defaultSeriesType = 'bar_stacked'; const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; +function getVisualizationType(state: State): VisualizationType | 'mixed' { + if (!state.layers.length) { + return ( + visualizationTypes.find(t => t.id === state.preferredSeriesType) ?? visualizationTypes[0] + ); + } + const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType); + const seriesTypes = _.unique(state.layers.map(l => l.seriesType)); + + return visualizationType && seriesTypes.length === 1 ? visualizationType : 'mixed'; +} + function getDescription(state?: State) { if (!state) { return { @@ -34,32 +46,31 @@ function getDescription(state?: State) { }; } + const visualizationType = getVisualizationType(state); + if (!state.layers.length) { - const visualizationType = visualizationTypes.find(v => v.id === state.preferredSeriesType)!; + const preferredType = visualizationType as VisualizationType; return { - icon: visualizationType.largeIcon || visualizationType.icon, - label: visualizationType.label, + icon: preferredType.largeIcon || preferredType.icon, + label: preferredType.label, }; } - const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType)!; - const seriesTypes = _.unique(state.layers.map(l => l.seriesType)); - return { icon: - seriesTypes.length === 1 - ? visualizationType.largeIcon || visualizationType.icon - : chartMixedSVG, + visualizationType === 'mixed' + ? chartMixedSVG + : visualizationType.largeIcon || visualizationType.icon, label: - seriesTypes.length === 1 - ? visualizationType.label - : isHorizontalChart(state.layers) - ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', { - defaultMessage: 'Mixed horizontal bar', - }) - : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { - defaultMessage: 'Mixed XY', - }), + visualizationType === 'mixed' + ? isHorizontalChart(state.layers) + ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', { + defaultMessage: 'Mixed horizontal bar', + }) + : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { + defaultMessage: 'Mixed XY', + }) + : visualizationType.label, }; } @@ -67,6 +78,10 @@ export const xyVisualization: Visualization = { id: 'lnsXY', visualizationTypes, + getVisualizationTypeId(state) { + const type = getVisualizationType(state); + return type === 'mixed' ? type : type.id; + }, getLayerIds(state) { return state.layers.map(l => l.layerId); From 9b65cbd92bb410b008ed8655937cc796369056e3 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 30 Apr 2020 02:10:14 +0200 Subject: [PATCH 12/13] [Lens] Bind all time fields to the time picker (#63874) * Bind non primary time fields to timepicker * Fix typescript argument types * Allow auto interval on all fields * Remove lens_auto_date function * Fix existing jest tests and add test todos * Remove lens_auto_date from esarchives * Add TimeBuckets jest tests * Fix typo in esarchiver * Address review feedback * Make code a bit better readable * Fix default time field retrieval * Fix TS errors * Add esaggs interpreter tests * Change public API doc of data plugin * Add toExpression tests for index pattern datasource * Add migration stub * Add full migration * Fix naming inconsistency in esaggs * Fix naming issue * Revert archives to un-migrated version * Ignore expressions that are already migrated * test: remove extra spaces and timeField=\\"products.created_on\\"} to timeField=\"products.created_on\"} * Rename all timeField -> timeFields * Combine duplicate functions * Fix boolean error and add test for it * Commit API changes Co-authored-by: Wylie Conlon Co-authored-by: Elastic Machine Co-authored-by: Marta Bondyra --- ...bana-plugin-plugins-data-public.gettime.md | 7 +- ...-data-public.iindexpattern.gettimefield.md | 15 +++ ...lugin-plugins-data-public.iindexpattern.md | 6 + .../kibana-plugin-plugins-data-public.md | 2 +- .../data/common/index_patterns/types.ts | 1 + src/plugins/data/public/public.api.md | 7 +- .../public/query/timefilter/get_time.test.ts | 38 ++++++ .../data/public/query/timefilter/get_time.ts | 27 ++-- .../data/public/query/timefilter/index.ts | 2 +- .../public/query/timefilter/timefilter.ts | 4 +- .../search/aggs/buckets/date_histogram.ts | 2 +- .../data/public/search/expressions/esaggs.ts | 43 +++++- .../data/public/search/tabify/buckets.test.ts | 63 +++++++-- .../data/public/search/tabify/buckets.ts | 12 +- .../data/public/search/tabify/tabify.ts | 18 +-- .../data/public/search/tabify/types.ts | 10 +- src/plugins/data/server/server.api.md | 2 + .../test_suites/run_pipeline/esaggs.ts | 93 +++++++++++++ .../test_suites/run_pipeline/index.ts | 1 + .../indexpattern_datasource/auto_date.test.ts | 83 ------------ .../indexpattern_datasource/auto_date.ts | 79 ------------ .../public/indexpattern_datasource/index.ts | 4 +- .../indexpattern.test.ts | 102 +++++++++++++-- .../indexpattern_datasource/to_expression.ts | 23 ++-- x-pack/plugins/lens/server/migrations.test.ts | 120 +++++++++++++++++ x-pack/plugins/lens/server/migrations.ts | 122 +++++++++++++++++- 26 files changed, 635 insertions(+), 251 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md create mode 100644 test/interpreter_functional/test_suites/run_pipeline/esaggs.ts delete mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts delete mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md index 04a0d871cab2d..3969a97fa7789 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.gettime.md @@ -7,7 +7,10 @@ Signature: ```typescript -export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined; +export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: { + forceNow?: Date; + fieldName?: string; +}): import("../..").RangeFilter | undefined; ``` ## Parameters @@ -16,7 +19,7 @@ export declare function getTime(indexPattern: IIndexPattern | undefined, timeRan | --- | --- | --- | | indexPattern | IIndexPattern | undefined | | | timeRange | TimeRange | | -| forceNow | Date | | +| options | {
forceNow?: Date;
fieldName?: string;
} | | Returns: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md new file mode 100644 index 0000000000000..c3998876c9712 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) > [getTimeField](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md) + +## IIndexPattern.getTimeField() method + +Signature: + +```typescript +getTimeField?(): IFieldType | undefined; +``` +Returns: + +`IFieldType | undefined` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md index 1bbd6cf67f0ce..1cb89822eb605 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpattern.md @@ -21,3 +21,9 @@ export interface IIndexPattern | [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | string | | | [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | string | | +## Methods + +| Method | Description | +| --- | --- | +| [getTimeField()](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md) | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 0fd82ffb2240c..e1df493143b73 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -43,7 +43,7 @@ | [getEsPreference(uiSettings, sessionId)](./kibana-plugin-plugins-data-public.getespreference.md) | | | [getQueryLog(uiSettings, storage, appName, language)](./kibana-plugin-plugins-data-public.getquerylog.md) | | | [getSearchErrorType({ message })](./kibana-plugin-plugins-data-public.getsearcherrortype.md) | | -| [getTime(indexPattern, timeRange, forceNow)](./kibana-plugin-plugins-data-public.gettime.md) | | +| [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | | ## Interfaces diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 698edbf9cd6a8..e21d27a70e02a 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -26,6 +26,7 @@ export interface IIndexPattern { id?: string; type?: string; timeFieldName?: string; + getTimeField?(): IFieldType | undefined; fieldFormatMap?: Record< string, { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 86560b3ccf7b1..91dea66f06a94 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -699,7 +699,10 @@ export function getSearchErrorType({ message }: Pick): " // Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined; +export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: { + forceNow?: Date; + fieldName?: string; +}): import("../..").RangeFilter | undefined; // Warning: (ae-missing-release-tag) "IAggConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -842,6 +845,8 @@ export interface IIndexPattern { // (undocumented) fields: IFieldType[]; // (undocumented) + getTimeField?(): IFieldType | undefined; + // (undocumented) id?: string; // (undocumented) timeFieldName?: string; diff --git a/src/plugins/data/public/query/timefilter/get_time.test.ts b/src/plugins/data/public/query/timefilter/get_time.test.ts index a8eb3a3fe8102..4dba157a6f554 100644 --- a/src/plugins/data/public/query/timefilter/get_time.test.ts +++ b/src/plugins/data/public/query/timefilter/get_time.test.ts @@ -51,5 +51,43 @@ describe('get_time', () => { }); clock.restore(); }); + + test('build range filter for non-primary field', () => { + const clock = sinon.useFakeTimers(moment.utc([2000, 1, 1, 0, 0, 0, 0]).valueOf()); + + const filter = getTime( + { + id: 'test', + title: 'test', + timeFieldName: 'date', + fields: [ + { + name: 'date', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + filterable: true, + }, + { + name: 'myCustomDate', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + filterable: true, + }, + ], + } as any, + { from: 'now-60y', to: 'now' }, + { fieldName: 'myCustomDate' } + ); + expect(filter!.range.myCustomDate).toEqual({ + gte: '1940-02-01T00:00:00.000Z', + lte: '2000-02-01T00:00:00.000Z', + format: 'strict_date_optional_time', + }); + clock.restore(); + }); }); }); diff --git a/src/plugins/data/public/query/timefilter/get_time.ts b/src/plugins/data/public/query/timefilter/get_time.ts index fa15406189041..9cdd25d3213ce 100644 --- a/src/plugins/data/public/query/timefilter/get_time.ts +++ b/src/plugins/data/public/query/timefilter/get_time.ts @@ -19,7 +19,7 @@ import dateMath from '@elastic/datemath'; import { IIndexPattern } from '../..'; -import { TimeRange, IFieldType, buildRangeFilter } from '../../../common'; +import { TimeRange, buildRangeFilter } from '../../../common'; interface CalculateBoundsOptions { forceNow?: Date; @@ -35,18 +35,27 @@ export function calculateBounds(timeRange: TimeRange, options: CalculateBoundsOp export function getTime( indexPattern: IIndexPattern | undefined, timeRange: TimeRange, + options?: { forceNow?: Date; fieldName?: string } +) { + return createTimeRangeFilter( + indexPattern, + timeRange, + options?.fieldName || indexPattern?.timeFieldName, + options?.forceNow + ); +} + +function createTimeRangeFilter( + indexPattern: IIndexPattern | undefined, + timeRange: TimeRange, + fieldName?: string, forceNow?: Date ) { if (!indexPattern) { - // in CI, we sometimes seem to fail here. return; } - - const timefield: IFieldType | undefined = indexPattern.fields.find( - field => field.name === indexPattern.timeFieldName - ); - - if (!timefield) { + const field = indexPattern.fields.find(f => f.name === (fieldName || indexPattern.timeFieldName)); + if (!field) { return; } @@ -55,7 +64,7 @@ export function getTime( return; } return buildRangeFilter( - timefield, + field, { ...(bounds.min && { gte: bounds.min.toISOString() }), ...(bounds.max && { lte: bounds.max.toISOString() }), diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts index a6260e782c12f..034af03842ab8 100644 --- a/src/plugins/data/public/query/timefilter/index.ts +++ b/src/plugins/data/public/query/timefilter/index.ts @@ -22,6 +22,6 @@ export { TimefilterService, TimefilterSetup } from './timefilter_service'; export * from './types'; export { Timefilter, TimefilterContract } from './timefilter'; export { TimeHistory, TimeHistoryContract } from './time_history'; -export { getTime } from './get_time'; +export { getTime, calculateBounds } from './get_time'; export { changeTimeFilter } from './lib/change_time_filter'; export { extractTimeFilter } from './lib/extract_time_filter'; diff --git a/src/plugins/data/public/query/timefilter/timefilter.ts b/src/plugins/data/public/query/timefilter/timefilter.ts index 4fbdac47fb3b0..86ef69be572a9 100644 --- a/src/plugins/data/public/query/timefilter/timefilter.ts +++ b/src/plugins/data/public/query/timefilter/timefilter.ts @@ -164,7 +164,9 @@ export class Timefilter { }; public createFilter = (indexPattern: IndexPattern, timeRange?: TimeRange) => { - return getTime(indexPattern, timeRange ? timeRange : this._time, this.getForceNow()); + return getTime(indexPattern, timeRange ? timeRange : this._time, { + forceNow: this.getForceNow(), + }); }; public getBounds(): TimeRangeBounds { diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts index 57f3aa85ad944..3ecdc17cb57f3 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -45,7 +45,7 @@ const updateTimeBuckets = ( customBuckets?: IBucketDateHistogramAggConfig['buckets'] ) => { const bounds = - agg.params.timeRange && agg.fieldIsTimeField() + agg.params.timeRange && (agg.fieldIsTimeField() || agg.params.interval === 'auto') ? timefilter.calculateBounds(agg.params.timeRange) : undefined; const buckets = customBuckets || agg.buckets; diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 087b83127079f..eec75b0841133 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -32,8 +32,15 @@ import { Adapters } from '../../../../../plugins/inspector/public'; import { IAggConfigs } from '../aggs'; import { ISearchSource } from '../search_source'; import { tabifyAggResponse } from '../tabify'; -import { Filter, Query, serializeFieldFormat, TimeRange } from '../../../common'; -import { FilterManager, getTime } from '../../query'; +import { + Filter, + Query, + serializeFieldFormat, + TimeRange, + IIndexPattern, + isRangeFilter, +} from '../../../common'; +import { FilterManager, calculateBounds, getTime } from '../../query'; import { getSearchService, getQueryService, getIndexPatterns } from '../../services'; import { buildTabularInspectorData } from './build_tabular_inspector_data'; import { getRequestInspectorStats, getResponseInspectorStats, serializeAggConfig } from './utils'; @@ -42,6 +49,8 @@ export interface RequestHandlerParams { searchSource: ISearchSource; aggs: IAggConfigs; timeRange?: TimeRange; + timeFields?: string[]; + indexPattern?: IIndexPattern; query?: Query; filters?: Filter[]; forceFetch: boolean; @@ -65,12 +74,15 @@ interface Arguments { partialRows: boolean; includeFormatHints: boolean; aggConfigs: string; + timeFields?: string[]; } const handleCourierRequest = async ({ searchSource, aggs, timeRange, + timeFields, + indexPattern, query, filters, forceFetch, @@ -111,9 +123,19 @@ const handleCourierRequest = async ({ return aggs.onSearchRequestStart(paramSearchSource, options); }); - if (timeRange) { + // If timeFields have been specified, use the specified ones, otherwise use primary time field of index + // pattern if it's available. + const defaultTimeField = indexPattern?.getTimeField?.(); + const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : []; + const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields; + + // If a timeRange has been specified and we had at least one timeField available, create range + // filters for that those time fields + if (timeRange && allTimeFields.length > 0) { timeFilterSearchSource.setField('filter', () => { - return getTime(searchSource.getField('index'), timeRange); + return allTimeFields + .map(fieldName => getTime(indexPattern, timeRange, { fieldName })) + .filter(isRangeFilter); }); } @@ -181,11 +203,13 @@ const handleCourierRequest = async ({ (searchSource as any).finalResponse = resp; - const parsedTimeRange = timeRange ? getTime(aggs.indexPattern, timeRange) : null; + const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null; const tabifyParams = { metricsAtAllLevels, partialRows, - timeRange: parsedTimeRange ? parsedTimeRange.range : undefined, + timeRange: parsedTimeRange + ? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields } + : undefined, }; const tabifyCacheHash = calculateObjectHash({ tabifyAggs: aggs, ...tabifyParams }); @@ -242,6 +266,11 @@ export const esaggs = (): ExpressionFunctionDefinition { const check = (aggResp: any, count: number, keys: string[]) => { @@ -187,9 +192,9 @@ describe('Buckets wrapper', () => { }, }; const timeRange = { - gte: 150, - lte: 350, - name: 'date', + from: moment(150), + to: moment(350), + timeFields: ['date'], }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); @@ -204,9 +209,9 @@ describe('Buckets wrapper', () => { }, }; const timeRange = { - gte: 150, - lte: 350, - name: 'date', + from: moment(150), + to: moment(350), + timeFields: ['date'], }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); @@ -221,9 +226,9 @@ describe('Buckets wrapper', () => { }, }; const timeRange = { - gte: 100, - lte: 400, - name: 'date', + from: moment(100), + to: moment(400), + timeFields: ['date'], }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); @@ -238,13 +243,47 @@ describe('Buckets wrapper', () => { }, }; const timeRange = { - gte: 150, - lte: 350, - name: 'date', + from: moment(150), + to: moment(350), + timeFields: ['date'], }; const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); expect(buckets).toHaveLength(4); }); + + test('does drop bucket when multiple time fields specified', () => { + const aggParams = { + drop_partials: true, + field: { + name: 'date', + }, + }; + const timeRange = { + from: moment(100), + to: moment(350), + timeFields: ['date', 'other_datefield'], + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + + expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([100, 200]); + }); + + test('does not drop bucket when no timeFields have been specified', () => { + const aggParams = { + drop_partials: true, + field: { + name: 'date', + }, + }; + const timeRange = { + from: moment(100), + to: moment(350), + timeFields: [], + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + + expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([0, 100, 200, 300]); + }); }); }); diff --git a/src/plugins/data/public/search/tabify/buckets.ts b/src/plugins/data/public/search/tabify/buckets.ts index 971e820ac6ddf..cd52a09caeaad 100644 --- a/src/plugins/data/public/search/tabify/buckets.ts +++ b/src/plugins/data/public/search/tabify/buckets.ts @@ -20,7 +20,7 @@ import { get, isPlainObject, keys, findKey } from 'lodash'; import moment from 'moment'; import { IAggConfig } from '../aggs'; -import { AggResponseBucket, TabbedRangeFilterParams } from './types'; +import { AggResponseBucket, TabbedRangeFilterParams, TimeRangeInformation } from './types'; type AggParams = IAggConfig['params'] & { drop_partials: boolean; @@ -36,7 +36,7 @@ export class TabifyBuckets { buckets: any; _keys: any[] = []; - constructor(aggResp: any, aggParams?: AggParams, timeRange?: TabbedRangeFilterParams) { + constructor(aggResp: any, aggParams?: AggParams, timeRange?: TimeRangeInformation) { if (aggResp && aggResp.buckets) { this.buckets = aggResp.buckets; } else if (aggResp) { @@ -107,12 +107,12 @@ export class TabifyBuckets { // dropPartials should only be called if the aggParam setting is enabled, // and the agg field is the same as the Time Range. - private dropPartials(params: AggParams, timeRange?: TabbedRangeFilterParams) { + private dropPartials(params: AggParams, timeRange?: TimeRangeInformation) { if ( !timeRange || this.buckets.length <= 1 || this.objectMode || - params.field.name !== timeRange.name + !timeRange.timeFields.includes(params.field.name) ) { return; } @@ -120,10 +120,10 @@ export class TabifyBuckets { const interval = this.buckets[1].key - this.buckets[0].key; this.buckets = this.buckets.filter((bucket: AggResponseBucket) => { - if (moment(bucket.key).isBefore(timeRange.gte)) { + if (moment(bucket.key).isBefore(timeRange.from)) { return false; } - if (moment(bucket.key + interval).isAfter(timeRange.lte)) { + if (moment(bucket.key + interval).isAfter(timeRange.to)) { return false; } return true; diff --git a/src/plugins/data/public/search/tabify/tabify.ts b/src/plugins/data/public/search/tabify/tabify.ts index e93e989034252..9cb55f94537c5 100644 --- a/src/plugins/data/public/search/tabify/tabify.ts +++ b/src/plugins/data/public/search/tabify/tabify.ts @@ -20,7 +20,7 @@ import { get } from 'lodash'; import { TabbedAggResponseWriter } from './response_writer'; import { TabifyBuckets } from './buckets'; -import { TabbedResponseWriterOptions, TabbedRangeFilterParams } from './types'; +import { TabbedResponseWriterOptions } from './types'; import { AggResponseBucket } from './types'; import { AggGroupNames, IAggConfigs } from '../aggs'; @@ -54,7 +54,7 @@ export function tabifyAggResponse( switch (agg.type.type) { case AggGroupNames.Buckets: const aggBucket = get(bucket, agg.id); - const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, timeRange); + const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, respOpts?.timeRange); if (tabifyBuckets.length) { tabifyBuckets.forEach((subBucket, tabifyBucketKey) => { @@ -153,20 +153,6 @@ export function tabifyAggResponse( doc_count: esResponse.hits.total, }; - let timeRange: TabbedRangeFilterParams | undefined; - - // Extract the time range object if provided - if (respOpts && respOpts.timeRange) { - const [timeRangeKey] = Object.keys(respOpts.timeRange); - - if (timeRangeKey) { - timeRange = { - name: timeRangeKey, - ...respOpts.timeRange[timeRangeKey], - }; - } - } - collectBucket(aggConfigs, write, topLevelBucket, '', 1); return write.response(); diff --git a/src/plugins/data/public/search/tabify/types.ts b/src/plugins/data/public/search/tabify/types.ts index 1e051880d3f19..72e91eb58c8a9 100644 --- a/src/plugins/data/public/search/tabify/types.ts +++ b/src/plugins/data/public/search/tabify/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import { Moment } from 'moment'; import { RangeFilterParams } from '../../../common'; import { IAggConfig } from '../aggs'; @@ -25,11 +26,18 @@ export interface TabbedRangeFilterParams extends RangeFilterParams { name: string; } +/** @internal */ +export interface TimeRangeInformation { + from?: Moment; + to?: Moment; + timeFields: string[]; +} + /** @internal **/ export interface TabbedResponseWriterOptions { metricsAtAllLevels: boolean; partialRows: boolean; - timeRange?: { [key: string]: RangeFilterParams }; + timeRange?: TimeRangeInformation; } /** @internal */ diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 5d94b6516c2ba..df4ba23244b4d 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -408,6 +408,8 @@ export interface IIndexPattern { // (undocumented) fields: IFieldType[]; // (undocumented) + getTimeField?(): IFieldType | undefined; + // (undocumented) id?: string; // (undocumented) timeFieldName?: string; diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts new file mode 100644 index 0000000000000..5ea151dffdc8e --- /dev/null +++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs.ts @@ -0,0 +1,93 @@ +/* + * 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 expect from '@kbn/expect'; +import { ExpectExpression, expectExpressionProvider } from './helpers'; +import { FtrProviderContext } from '../../../functional/ftr_provider_context'; + +function getCell(esaggsResult: any, column: number, row: number): unknown | undefined { + const columnId = esaggsResult?.columns[column]?.id; + if (!columnId) { + return; + } + return esaggsResult?.rows[row]?.[columnId]; +} + +export default function({ + getService, + updateBaselines, +}: FtrProviderContext & { updateBaselines: boolean }) { + let expectExpression: ExpectExpression; + describe('esaggs pipeline expression tests', () => { + before(() => { + expectExpression = expectExpressionProvider({ getService, updateBaselines }); + }); + + describe('correctly renders tagcloud', () => { + it('filters on index pattern primary date field by default', async () => { + const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }]; + const timeRange = { + from: '2006-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + const expression = ` + kibana_context timeRange='${JSON.stringify(timeRange)}' + | esaggs index='logstash-*' aggConfigs='${JSON.stringify(aggConfigs)}' + `; + const result = await expectExpression('esaggs_primary_timefield', expression).getResponse(); + expect(getCell(result, 0, 0)).to.be(9375); + }); + + it('filters on the specified date field', async () => { + const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }]; + const timeRange = { + from: '2006-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + const expression = ` + kibana_context timeRange='${JSON.stringify(timeRange)}' + | esaggs index='logstash-*' timeFields='relatedContent.article:published_time' aggConfigs='${JSON.stringify( + aggConfigs + )}' + `; + const result = await expectExpression('esaggs_other_timefield', expression).getResponse(); + expect(getCell(result, 0, 0)).to.be(11134); + }); + + it('filters on multiple specified date field', async () => { + const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }]; + const timeRange = { + from: '2006-09-21T00:00:00Z', + to: '2015-09-22T00:00:00Z', + }; + const expression = ` + kibana_context timeRange='${JSON.stringify(timeRange)}' + | esaggs index='logstash-*' timeFields='relatedContent.article:published_time' timeFields='@timestamp' aggConfigs='${JSON.stringify( + aggConfigs + )}' + `; + const result = await expectExpression( + 'esaggs_multiple_timefields', + expression + ).getResponse(); + expect(getCell(result, 0, 0)).to.be(7452); + }); + }); + }); +} diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts index 031a0e3576ccc..9590f9f8c1794 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/index.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts @@ -46,5 +46,6 @@ export default function({ getService, getPageObjects, loadTestFile }: FtrProvide loadTestFile(require.resolve('./basic')); loadTestFile(require.resolve('./tag_cloud')); loadTestFile(require.resolve('./metric')); + loadTestFile(require.resolve('./esaggs')); }); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts deleted file mode 100644 index 5f35ef650a08c..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.test.ts +++ /dev/null @@ -1,83 +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 { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; -import { getAutoDate } from './auto_date'; - -describe('auto_date', () => { - let autoDate: ReturnType; - - beforeEach(() => { - autoDate = getAutoDate({ data: dataPluginMock.createSetupContract() }); - }); - - it('should do nothing if no time range is provided', () => { - const result = autoDate.fn( - { - type: 'kibana_context', - }, - { - aggConfigs: 'canttouchthis', - }, - // eslint-disable-next-line - {} as any - ); - - expect(result).toEqual('canttouchthis'); - }); - - it('should not change anything if there are no auto date histograms', () => { - const aggConfigs = JSON.stringify([ - { type: 'date_histogram', params: { interval: '35h' } }, - { type: 'count' }, - ]); - const result = autoDate.fn( - { - timeRange: { - from: 'now-10d', - to: 'now', - }, - type: 'kibana_context', - }, - { - aggConfigs, - }, - // eslint-disable-next-line - {} as any - ); - - expect(result).toEqual(aggConfigs); - }); - - it('should change auto date histograms', () => { - const aggConfigs = JSON.stringify([ - { type: 'date_histogram', params: { interval: 'auto' } }, - { type: 'count' }, - ]); - const result = autoDate.fn( - { - timeRange: { - from: 'now-10d', - to: 'now', - }, - type: 'kibana_context', - }, - { - aggConfigs, - }, - // eslint-disable-next-line - {} as any - ); - - const interval = JSON.parse(result).find( - (agg: { type: string }) => agg.type === 'date_histogram' - ).params.interval; - - expect(interval).toBeTruthy(); - expect(typeof interval).toEqual('string'); - expect(interval).not.toEqual('auto'); - }); -}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts b/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts deleted file mode 100644 index 97a46f4a3e176..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/auto_date.ts +++ /dev/null @@ -1,79 +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 { DataPublicPluginSetup } from '../../../../../src/plugins/data/public'; -import { - ExpressionFunctionDefinition, - KibanaContext, -} from '../../../../../src/plugins/expressions/public'; - -interface LensAutoDateProps { - aggConfigs: string; -} - -export function getAutoDate(deps: { - data: DataPublicPluginSetup; -}): ExpressionFunctionDefinition< - 'lens_auto_date', - KibanaContext | null, - LensAutoDateProps, - string -> { - function autoIntervalFromContext(ctx?: KibanaContext | null) { - if (!ctx || !ctx.timeRange) { - return; - } - - return deps.data.search.aggs.calculateAutoTimeExpression(ctx.timeRange); - } - - /** - * Convert all 'auto' date histograms into a concrete value (e.g. 2h). - * This allows us to support 'auto' on all date fields, and opens the - * door to future customizations (e.g. adjusting the level of detail, etc). - */ - return { - name: 'lens_auto_date', - aliases: [], - help: '', - inputTypes: ['kibana_context', 'null'], - args: { - aggConfigs: { - types: ['string'], - default: '""', - help: '', - }, - }, - fn(input, args) { - const interval = autoIntervalFromContext(input); - - if (!interval) { - return args.aggConfigs; - } - - const configs = JSON.parse(args.aggConfigs) as Array<{ - type: string; - params: { interval: string }; - }>; - - const updatedConfigs = configs.map(c => { - if (c.type !== 'date_histogram' || !c.params || c.params.interval !== 'auto') { - return c; - } - - return { - ...c, - params: { - ...c.params, - interval, - }, - }; - }); - - return JSON.stringify(updatedConfigs); - }, - }; -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index fe14f472341af..73fd144b9c7f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -8,7 +8,6 @@ import { CoreSetup } from 'kibana/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { getIndexPatternDatasource } from './indexpattern'; import { renameColumns } from './rename_columns'; -import { getAutoDate } from './auto_date'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { DataPublicPluginSetup, @@ -31,10 +30,9 @@ export class IndexPatternDatasource { setup( core: CoreSetup, - { data: dataSetup, expressions, editorFrame }: IndexPatternDatasourceSetupPlugins + { expressions, editorFrame }: IndexPatternDatasourceSetupPlugins ) { expressions.registerFunction(renameColumns); - expressions.registerFunction(getAutoDate({ data: dataSetup })); editorFrame.registerDatasource( core.getStartServices().then(([coreStart, { data }]) => diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index e4f3677d0fe88..06635e663361d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -10,6 +10,7 @@ import { DatasourcePublicAPI, Operation, Datasource } from '../types'; import { coreMock } from 'src/core/public/mocks'; import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { Ast } from '@kbn/interpreter/common'; jest.mock('./loader'); jest.mock('../id_generator'); @@ -262,20 +263,7 @@ describe('IndexPattern Data Source', () => { Object { "arguments": Object { "aggConfigs": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object { - "aggConfigs": Array [ - "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", - ], - }, - "function": "lens_auto_date", - "type": "function", - }, - ], - "type": "expression", - }, + "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", ], "includeFormatHints": Array [ true, @@ -289,6 +277,9 @@ describe('IndexPattern Data Source', () => { "partialRows": Array [ false, ], + "timeFields": Array [ + "timestamp", + ], }, "function": "esaggs", "type": "function", @@ -307,6 +298,89 @@ describe('IndexPattern Data Source', () => { } `); }); + + it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + col3: { + label: 'Date 2', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'another_datefield', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + }); + + it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'cardinality', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + }); }); describe('#insertLayer', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 3ab51b5fa3f2b..1308fa3b7ca60 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -10,6 +10,7 @@ import { IndexPatternColumn } from './indexpattern'; import { operationDefinitionMap } from './operations'; import { IndexPattern, IndexPatternPrivateState } from './types'; import { OriginalColumn } from './rename_columns'; +import { dateHistogramOperation } from './operations/definitions'; function getExpressionForLayer( indexPattern: IndexPattern, @@ -68,6 +69,12 @@ function getExpressionForLayer( return base; }); + const allDateHistogramFields = Object.values(columns) + .map(column => + column.operationType === dateHistogramOperation.type ? column.sourceField : null + ) + .filter((field): field is string => Boolean(field)); + return { type: 'expression', chain: [ @@ -79,20 +86,8 @@ function getExpressionForLayer( metricsAtAllLevels: [false], partialRows: [false], includeFormatHints: [true], - aggConfigs: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_auto_date', - arguments: { - aggConfigs: [JSON.stringify(aggs)], - }, - }, - ], - }, - ], + timeFields: allDateHistogramFields, + aggConfigs: [JSON.stringify(aggs)], }, }, { diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index e80308cc9acdb..4cc330d40efd7 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -158,4 +158,124 @@ describe('Lens migrations', () => { ]); }); }); + + describe('7.8.0 auto timestamp', () => { + const context = {} as SavedObjectMigrationContext; + + const example = { + type: 'lens', + attributes: { + expression: `kibana + | kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]" + | lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c" + tables={esaggs + index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" + metricsAtAllLevels=false + partialRows=false + includeFormatHints=true + aggConfigs={ + lens_auto_date + aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" + } + | lens_rename_columns idMap="{\\"col-0-1d9cc16c-1460-41de-88f8-471932ecbc97\\":{\\"label\\":\\"products.created_on\\",\\"dataType\\":\\"date\\",\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"products.created_on\\",\\"isBucketed\\":true,\\"scale\\":\\"interval\\",\\"params\\":{\\"interval\\":\\"auto\\"},\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\"},\\"col-1-66115819-8481-4917-a6dc-8ffb10dd02df\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"suggestedPriority\\":0,\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\"}}" + } + | lens_xy_chart + xTitle="products.created_on" + yTitle="Count of records" + legend={lens_xy_legendConfig isVisible=true position="right"} + layers={lens_xy_layer + layerId="bd09dc71-a7e2-42d0-83bd-85df8291f03c" + hide=false + xAccessor="1d9cc16c-1460-41de-88f8-471932ecbc97" + yScaleType="linear" + xScaleType="time" + isHistogram=true + seriesType="bar_stacked" + accessors="66115819-8481-4917-a6dc-8ffb10dd02df" + columnToLabel="{\\"66115819-8481-4917-a6dc-8ffb10dd02df\\":\\"Count of records\\"}" + } + `, + state: { + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + layers: { + 'bd09dc71-a7e2-42d0-83bd-85df8291f03c': { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + columns: { + '1d9cc16c-1460-41de-88f8-471932ecbc97': { + label: 'products.created_on', + dataType: 'date', + operationType: 'date_histogram', + sourceField: 'products.created_on', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '66115819-8481-4917-a6dc-8ffb10dd02df': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + suggestedPriority: 0, + isBucketed: false, + scale: 'ratio', + sourceField: 'Records', + }, + }, + columnOrder: [ + '1d9cc16c-1460-41de-88f8-471932ecbc97', + '66115819-8481-4917-a6dc-8ffb10dd02df', + ], + }, + }, + }, + }, + datasourceMetaData: { + filterableIndexPatterns: [ + { id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', title: 'kibana_sample_data_ecommerce' }, + ], + }, + visualization: { + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: 'bd09dc71-a7e2-42d0-83bd-85df8291f03c', + accessors: ['66115819-8481-4917-a6dc-8ffb10dd02df'], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '1d9cc16c-1460-41de-88f8-471932ecbc97', + }, + ], + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + title: 'Bar chart', + visualizationType: 'lnsXY', + }, + }; + + it('should remove the lens_auto_date expression', () => { + const result = migrations['7.8.0'](example, context); + expect(result.attributes.expression).toContain(`timeFields=\"products.created_on\"`); + }); + + it('should handle pre-migrated expression', () => { + const input = { + type: 'lens', + attributes: { + ...example.attributes, + expression: `kibana +| kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]" +| lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c" + tables={esaggs index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" timeFields=\"products.created_on\"} +| lens_xy_chart xTitle="products.created_on" yTitle="Count of records" legend={lens_xy_legendConfig isVisible=true position="right"} layers={}`, + }, + }; + const result = migrations['7.8.0'](input, context); + expect(result).toEqual(input); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index 3d238723b7438..51fcd3b6198c3 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep } from 'lodash'; +import { cloneDeep, flow } from 'lodash'; +import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; import { SavedObjectMigrationFn } from 'src/core/server'; interface XYLayerPre77 { @@ -14,6 +15,122 @@ interface XYLayerPre77 { accessors: string[]; } +/** + * Removes the `lens_auto_date` subexpression from a stored expression + * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} + */ +const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => { + const expression: string = doc.attributes?.expression; + try { + const ast = fromExpression(expression); + const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => { + if (topNode.function !== 'lens_merge_tables') { + return topNode; + } + return { + ...topNode, + arguments: { + ...topNode.arguments, + tables: (topNode.arguments.tables as Ast[]).map(middleNode => { + return { + type: 'expression', + chain: middleNode.chain.map(node => { + // Check for sub-expression in aggConfigs + if ( + node.function === 'esaggs' && + typeof node.arguments.aggConfigs[0] !== 'string' + ) { + return { + ...node, + arguments: { + ...node.arguments, + aggConfigs: (node.arguments.aggConfigs[0] as Ast).chain[0].arguments + .aggConfigs, + }, + }; + } + return node; + }), + }; + }), + }, + }; + }); + + return { + ...doc, + attributes: { + ...doc.attributes, + expression: toExpression({ ...ast, chain: newChain }), + }, + }; + } catch (e) { + context.log.warning(e.message); + return { ...doc }; + } +}; + +/** + * Adds missing timeField arguments to esaggs in the Lens expression + */ +const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => { + const expression: string = doc.attributes?.expression; + + try { + const ast = fromExpression(expression); + const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => { + if (topNode.function !== 'lens_merge_tables') { + return topNode; + } + return { + ...topNode, + arguments: { + ...topNode.arguments, + tables: (topNode.arguments.tables as Ast[]).map(middleNode => { + return { + type: 'expression', + chain: middleNode.chain.map(node => { + // Skip if there are any timeField arguments already, because that indicates + // the fix is already applied + if (node.function !== 'esaggs' || node.arguments.timeFields) { + return node; + } + const timeFields: string[] = []; + JSON.parse(node.arguments.aggConfigs[0] as string).forEach( + (agg: { type: string; params: { field: string } }) => { + if (agg.type !== 'date_histogram') { + return; + } + timeFields.push(agg.params.field); + } + ); + return { + ...node, + arguments: { + ...node.arguments, + timeFields, + }, + }; + }), + }; + }), + }, + }; + }); + + return { + ...doc, + attributes: { + ...doc.attributes, + expression: toExpression({ ...ast, chain: newChain }), + }, + }; + } catch (e) { + context.log.warning(e.message); + return { ...doc }; + } +}; + export const migrations: Record = { '7.7.0': doc => { const newDoc = cloneDeep(doc); @@ -34,4 +151,7 @@ export const migrations: Record = { } return newDoc; }, + // The order of these migrations matter, since the timefield migration relies on the aggConfigs + // sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was). + '7.8.0': flow(removeLensAutoDate, addTimeFieldToEsaggs), }; From fba5128bd85d66c346abd1d3762ac6c57cd4a777 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 29 Apr 2020 18:03:30 -0700 Subject: [PATCH 13/13] [Ingest] Edit datasource UI (#64727) * Adjust NewDatasource type to exclude stream `agent_stream` property, add additional datasource hooks * Initial pass at edit datasource UI * Clean up dupe code, fix submit button not enabled after re-selecting a package * Remove delete config functionality from list page * Show validation errors for data source name and description fields * Fix types * Add success toasts * Review fixes, clean up i18n Co-authored-by: Elastic Machine --- .../datasource_to_agent_datasource.test.ts | 21 +- .../datasource_to_agent_datasource.ts | 8 +- .../common/types/models/agent_config.ts | 4 +- .../common/types/models/datasource.ts | 18 +- .../hooks/use_request/datasource.ts | 26 +- .../components/layout.tsx | 24 +- .../create_datasource_page/index.tsx | 26 +- .../step_configure_datasource.tsx | 62 +--- .../step_define_datasource.tsx | 13 +- .../create_datasource_page/types.ts | 3 +- .../datasources/datasources_table.tsx | 44 +-- .../edit_datasource_page/index.tsx | 323 ++++++++++++++++++ .../sections/agent_config/index.tsx | 4 + .../sections/agent_config/list_page/index.tsx | 69 +--- .../ingest_manager/types/index.ts | 2 + .../server/routes/agent_config/handlers.ts | 3 +- .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - 18 files changed, 462 insertions(+), 198 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts index a7d4e36d16f2a..bff799798ff6e 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Datasource, NewDatasource, DatasourceInput } from '../types'; +import { Datasource, DatasourceInput } from '../types'; import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { - const mockNewDatasource: NewDatasource = { + const mockDatasource: Datasource = { + id: 'some-uuid', name: 'mock-datasource', description: '', config_id: '', @@ -15,11 +16,6 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { output_id: '', namespace: 'default', inputs: [], - }; - - const mockDatasource: Datasource = { - ...mockNewDatasource, - id: 'some-uuid', revision: 1, }; @@ -107,17 +103,6 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { }); }); - it('uses name for id when id is not provided in case of new datasource', () => { - expect(storedDatasourceToAgentDatasource(mockNewDatasource)).toEqual({ - id: 'mock-datasource', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [], - }); - }); - it('returns agent datasource config with flattened input and package stream', () => { expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({ id: 'some-uuid', diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts index 5deb33ccf10f1..620b663451ea3 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts @@ -3,16 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Datasource, NewDatasource, FullAgentConfigDatasource } from '../types'; +import { Datasource, FullAgentConfigDatasource } from '../types'; import { DEFAULT_OUTPUT } from '../constants'; export const storedDatasourceToAgentDatasource = ( - datasource: Datasource | NewDatasource + datasource: Datasource ): FullAgentConfigDatasource => { - const { name, namespace, enabled, package: pkg, inputs } = datasource; + const { id, name, namespace, enabled, package: pkg, inputs } = datasource; const fullDatasource: FullAgentConfigDatasource = { - id: 'id' in datasource ? datasource.id : name, + id: id || name, name, namespace, enabled, diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index 7705956590c16..96121251b133e 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -3,8 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { SavedObjectAttributes } from 'src/core/public'; import { Datasource, DatasourcePackage, @@ -26,7 +24,7 @@ export interface NewAgentConfig { monitoring_enabled?: Array<'logs' | 'metrics'>; } -export interface AgentConfig extends NewAgentConfig, SavedObjectAttributes { +export interface AgentConfig extends NewAgentConfig { id: string; status: AgentConfigStatus; datasources: string[] | Datasource[]; diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts index 885e0a9316d79..ca61a93d9be93 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts @@ -17,22 +17,29 @@ export interface DatasourceConfigRecordEntry { export type DatasourceConfigRecord = Record; -export interface DatasourceInputStream { +export interface NewDatasourceInputStream { id: string; enabled: boolean; dataset: string; processors?: string[]; config?: DatasourceConfigRecord; vars?: DatasourceConfigRecord; +} + +export interface DatasourceInputStream extends NewDatasourceInputStream { agent_stream?: any; } -export interface DatasourceInput { +export interface NewDatasourceInput { type: string; enabled: boolean; processors?: string[]; config?: DatasourceConfigRecord; vars?: DatasourceConfigRecord; + streams: NewDatasourceInputStream[]; +} + +export interface DatasourceInput extends Omit { streams: DatasourceInputStream[]; } @@ -44,10 +51,11 @@ export interface NewDatasource { enabled: boolean; package?: DatasourcePackage; output_id: string; - inputs: DatasourceInput[]; + inputs: NewDatasourceInput[]; } -export type Datasource = NewDatasource & { +export interface Datasource extends Omit { id: string; + inputs: DatasourceInput[]; revision: number; -}; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts index 0d19ecd0cb735..e2fc190e158f9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts @@ -5,12 +5,18 @@ */ import { sendRequest, useRequest } from './use_request'; import { datasourceRouteService } from '../../services'; -import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types'; +import { + CreateDatasourceRequest, + CreateDatasourceResponse, + UpdateDatasourceRequest, + UpdateDatasourceResponse, +} from '../../types'; import { DeleteDatasourcesRequest, DeleteDatasourcesResponse, GetDatasourcesRequest, GetDatasourcesResponse, + GetOneDatasourceResponse, } from '../../../../../common/types/rest_spec'; export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { @@ -21,6 +27,17 @@ export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { }); }; +export const sendUpdateDatasource = ( + datasourceId: string, + body: UpdateDatasourceRequest['body'] +) => { + return sendRequest({ + path: datasourceRouteService.getUpdatePath(datasourceId), + method: 'put', + body: JSON.stringify(body), + }); +}; + export const sendDeleteDatasource = (body: DeleteDatasourcesRequest['body']) => { return sendRequest({ path: datasourceRouteService.getDeletePath(), @@ -36,3 +53,10 @@ export function useGetDatasources(query: GetDatasourcesRequest['query']) { query, }); } + +export const sendGetOneDatasource = (datasourceId: string) => { + return sendRequest({ + path: datasourceRouteService.getInfoPath(datasourceId), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index 39d882f7fdf65..f1e3fea6a0742 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -39,17 +39,29 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{

- + {from === 'edit' ? ( + + ) : ( + + )}

- {from === 'config' ? ( + {from === 'edit' ? ( + + ) : from === 'config' ? ( - {agentConfig && from === 'config' ? ( + {agentConfig && (from === 'config' || from === 'edit') ? ( { const updatePackageInfo = (updatedPackageInfo: PackageInfo | undefined) => { if (updatedPackageInfo) { setPackageInfo(updatedPackageInfo); + setFormState('VALID'); } else { setFormState('INVALID'); setPackageInfo(undefined); @@ -152,9 +153,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { const cancelUrl = from === 'config' ? CONFIG_URL : PACKAGE_URL; // Save datasource - const [formState, setFormState] = useState< - 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED' - >('INVALID'); + const [formState, setFormState] = useState('INVALID'); const saveDatasource = async () => { setFormState('LOADING'); const result = await sendCreateDatasource(datasource); @@ -174,6 +173,23 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { const { error } = await saveDatasource(); if (!error) { history.push(`${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}`); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.ingestManager.createDatasource.addedNotificationTitle', { + defaultMessage: `Successfully added '{datasourceName}'`, + values: { + datasourceName: datasource.name, + }, + }), + text: + agentCount && agentConfig + ? i18n.translate('xpack.ingestManager.createDatasource.addedNotificationMessage', { + defaultMessage: `Fleet will deploy updates to all agents that use the '{agentConfigName}' configuration`, + values: { + agentConfigName: agentConfig.name, + }, + }) + : undefined, + }); } else { notifications.toasts.addError(error, { title: 'Error', @@ -229,6 +245,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { packageInfo={packageInfo} datasource={datasource} updateDatasource={updateDatasource} + validationResults={validationResults!} /> ) : null, }, @@ -240,7 +257,6 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { children: agentConfig && packageInfo ? ( ) => void; validationResults: DatasourceValidationResults; submitAttempted: boolean; -}> = ({ - agentConfig, - packageInfo, - datasource, - updateDatasource, - validationResults, - submitAttempted, -}) => { - // Form show/hide states - +}> = ({ packageInfo, datasource, updateDatasource, validationResults, submitAttempted }) => { const hasErrors = validationResults ? validationHasErrors(validationResults) : false; - // Update datasource's package and config info - useEffect(() => { - const dsPackage = datasource.package; - const currentPkgKey = dsPackage ? `${dsPackage.name}-${dsPackage.version}` : ''; - const pkgKey = `${packageInfo.name}-${packageInfo.version}`; - - // If package has changed, create shell datasource with input&stream values based on package info - if (currentPkgKey !== pkgKey) { - // Existing datasources on the agent config using the package name, retrieve highest number appended to datasource name - const dsPackageNamePattern = new RegExp(`${packageInfo.name}-(\\d+)`); - const dsWithMatchingNames = (agentConfig.datasources as Datasource[]) - .filter(ds => Boolean(ds.name.match(dsPackageNamePattern))) - .map(ds => parseInt(ds.name.match(dsPackageNamePattern)![1], 10)) - .sort(); - - updateDatasource({ - name: `${packageInfo.name}-${ - dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1 - }`, - package: { - name: packageInfo.name, - title: packageInfo.title, - version: packageInfo.version, - }, - inputs: packageToConfigDatasourceInputs(packageInfo), - }); - } - - // If agent config has changed, update datasource's config ID and namespace - if (datasource.config_id !== agentConfig.id) { - updateDatasource({ - config_id: agentConfig.id, - namespace: agentConfig.namespace, - }); - } - }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]); - - // Step B, configure inputs (and their streams) + // Configure inputs (and their streams) // Assume packages only export one datasource for now const renderConfigureInputs = () => packageInfo.datasources && diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx index 792389381eaf0..c4d602c2c2081 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_define_datasource.tsx @@ -17,13 +17,16 @@ import { } from '@elastic/eui'; import { AgentConfig, PackageInfo, Datasource, NewDatasource } from '../../../types'; import { packageToConfigDatasourceInputs } from '../../../services'; +import { Loading } from '../../../components'; +import { DatasourceValidationResults } from './services'; export const StepDefineDatasource: React.FunctionComponent<{ agentConfig: AgentConfig; packageInfo: PackageInfo; datasource: NewDatasource; updateDatasource: (fields: Partial) => void; -}> = ({ agentConfig, packageInfo, datasource, updateDatasource }) => { + validationResults: DatasourceValidationResults; +}> = ({ agentConfig, packageInfo, datasource, updateDatasource, validationResults }) => { // Form show/hide states const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState(false); @@ -64,11 +67,13 @@ export const StepDefineDatasource: React.FunctionComponent<{ } }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]); - return ( + return validationResults ? ( <> } + isInvalid={!!validationResults.description} + error={validationResults.description} > ) : null} + ) : ( + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts index 85cc758fc4c46..10b30a5696d83 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export type CreateDatasourceFrom = 'package' | 'config'; +export type CreateDatasourceFrom = 'package' | 'config' | 'edit'; +export type DatasourceFormState = 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx index 1eee9f6b0c346..a0418c5f256c4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx @@ -19,7 +19,7 @@ import { import { AgentConfig, Datasource } from '../../../../../types'; import { TableRowActions } from '../../../components/table_row_actions'; import { DangerEuiContextMenuItem } from '../../../components/danger_eui_context_menu_item'; -import { useCapabilities } from '../../../../../hooks'; +import { useCapabilities, useLink } from '../../../../../hooks'; import { useAgentConfigLink } from '../../hooks/use_details_uri'; import { DatasourceDeleteProvider } from '../../../components/datasource_delete_provider'; import { useConfigRefresh } from '../../hooks/use_config'; @@ -56,6 +56,7 @@ export const DatasourcesTable: React.FunctionComponent = ({ }) => { const hasWriteCapabilities = useCapabilities().write; const addDatasourceLink = useAgentConfigLink('add-datasource', { configId: config.id }); + const editDatasourceLink = useLink(`/configs/${config.id}/edit-datasource`); const refreshConfig = useConfigRefresh(); // With the datasources provided on input, generate the list of datasources @@ -201,22 +202,21 @@ export const DatasourcesTable: React.FunctionComponent = ({ {}} + // key="datasourceView" + // > + // + // , {}} - key="datasourceView" - > - - , - // FIXME: implement Edit datasource action - {}} + href={`${editDatasourceLink}/${datasource.id}`} key="datasourceEdit" > = ({ /> , // FIXME: implement Copy datasource action - {}} key="datasourceCopy"> - - , + // {}} key="datasourceCopy"> + // + // , {deleteDatasourcePrompt => { return ( @@ -256,7 +256,7 @@ export const DatasourcesTable: React.FunctionComponent = ({ ], }, ], - [config, hasWriteCapabilities, refreshConfig] + [config, editDatasourceLink, hasWriteCapabilities, refreshConfig] ); return ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx new file mode 100644 index 0000000000000..d4c39f21a1ea6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx @@ -0,0 +1,323 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { useRouteMatch, useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButton, + EuiSteps, + EuiBottomBar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; +import { AgentConfig, PackageInfo, NewDatasource } from '../../../types'; +import { + useLink, + useCore, + useConfig, + sendUpdateDatasource, + sendGetAgentStatus, + sendGetOneAgentConfig, + sendGetOneDatasource, + sendGetPackageInfoByKey, +} from '../../../hooks'; +import { Loading, Error } from '../../../components'; +import { + CreateDatasourcePageLayout, + ConfirmCreateDatasourceModal, +} from '../create_datasource_page/components'; +import { + DatasourceValidationResults, + validateDatasource, + validationHasErrors, +} from '../create_datasource_page/services'; +import { DatasourceFormState, CreateDatasourceFrom } from '../create_datasource_page/types'; +import { StepConfigureDatasource } from '../create_datasource_page/step_configure_datasource'; +import { StepDefineDatasource } from '../create_datasource_page/step_define_datasource'; + +export const EditDatasourcePage: React.FunctionComponent = () => { + const { notifications } = useCore(); + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + const { + params: { configId, datasourceId }, + } = useRouteMatch(); + const history = useHistory(); + + // Agent config, package info, and datasource states + const [isLoadingData, setIsLoadingData] = useState(true); + const [loadingError, setLoadingError] = useState(); + const [agentConfig, setAgentConfig] = useState(); + const [packageInfo, setPackageInfo] = useState(); + const [datasource, setDatasource] = useState({ + name: '', + description: '', + config_id: '', + enabled: true, + output_id: '', + inputs: [], + }); + + // Retrieve agent config, package, and datasource info + useEffect(() => { + const getData = async () => { + setIsLoadingData(true); + setLoadingError(undefined); + try { + const [{ data: agentConfigData }, { data: datasourceData }] = await Promise.all([ + sendGetOneAgentConfig(configId), + sendGetOneDatasource(datasourceId), + ]); + if (agentConfigData?.item) { + setAgentConfig(agentConfigData.item); + } + if (datasourceData?.item) { + const { id, revision, inputs, ...restOfDatasource } = datasourceData.item; + // Remove `agent_stream` from all stream info, we assign this after saving + const newDatasource = { + ...restOfDatasource, + inputs: inputs.map(input => { + const { streams, ...restOfInput } = input; + return { + ...restOfInput, + streams: streams.map(stream => { + const { agent_stream, ...restOfStream } = stream; + return restOfStream; + }), + }; + }), + }; + setDatasource(newDatasource); + if (datasourceData.item.package) { + const { data: packageData } = await sendGetPackageInfoByKey( + `${datasourceData.item.package.name}-${datasourceData.item.package.version}` + ); + if (packageData?.response) { + setPackageInfo(packageData.response); + setValidationResults(validateDatasource(newDatasource, packageData.response)); + setFormState('VALID'); + } + } + } + } catch (e) { + setLoadingError(e); + } + setIsLoadingData(false); + }; + getData(); + }, [configId, datasourceId]); + + // Retrieve agent count + const [agentCount, setAgentCount] = useState(0); + useEffect(() => { + const getAgentCount = async () => { + const { data } = await sendGetAgentStatus({ configId }); + if (data?.results.total) { + setAgentCount(data.results.total); + } + }; + + if (isFleetEnabled) { + getAgentCount(); + } + }, [configId, isFleetEnabled]); + + // Datasource validation state + const [validationResults, setValidationResults] = useState(); + const hasErrors = validationResults ? validationHasErrors(validationResults) : false; + + // Update datasource method + const updateDatasource = (updatedFields: Partial) => { + const newDatasource = { + ...datasource, + ...updatedFields, + }; + setDatasource(newDatasource); + + // eslint-disable-next-line no-console + console.debug('Datasource updated', newDatasource); + const newValidationResults = updateDatasourceValidation(newDatasource); + const hasValidationErrors = newValidationResults + ? validationHasErrors(newValidationResults) + : false; + if (!hasValidationErrors) { + setFormState('VALID'); + } + }; + + const updateDatasourceValidation = (newDatasource?: NewDatasource) => { + if (packageInfo) { + const newValidationResult = validateDatasource(newDatasource || datasource, packageInfo); + setValidationResults(newValidationResult); + // eslint-disable-next-line no-console + console.debug('Datasource validation results', newValidationResult); + + return newValidationResult; + } + }; + + // Cancel url + const CONFIG_URL = useLink(`${AGENT_CONFIG_DETAILS_PATH}${configId}`); + const cancelUrl = CONFIG_URL; + + // Save datasource + const [formState, setFormState] = useState('INVALID'); + const saveDatasource = async () => { + setFormState('LOADING'); + const result = await sendUpdateDatasource(datasourceId, datasource); + setFormState('SUBMITTED'); + return result; + }; + + const onSubmit = async () => { + if (formState === 'VALID' && hasErrors) { + setFormState('INVALID'); + return; + } + if (agentCount !== 0 && formState !== 'CONFIRM') { + setFormState('CONFIRM'); + return; + } + const { error } = await saveDatasource(); + if (!error) { + history.push(`${AGENT_CONFIG_DETAILS_PATH}${configId}`); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.ingestManager.editDatasource.updatedNotificationTitle', { + defaultMessage: `Successfully updated '{datasourceName}'`, + values: { + datasourceName: datasource.name, + }, + }), + text: + agentCount && agentConfig + ? i18n.translate('xpack.ingestManager.editDatasource.updatedNotificationMessage', { + defaultMessage: `Fleet will deploy updates to all agents that use the '{agentConfigName}' configuration`, + values: { + agentConfigName: agentConfig.name, + }, + }) + : undefined, + }); + } else { + notifications.toasts.addError(error, { + title: 'Error', + }); + setFormState('VALID'); + } + }; + + const layoutProps = { + from: 'edit' as CreateDatasourceFrom, + cancelUrl, + agentConfig, + packageInfo, + }; + + return ( + + {isLoadingData ? ( + + ) : loadingError || !agentConfig || !packageInfo ? ( + + } + error={ + loadingError || + i18n.translate('xpack.ingestManager.editDatasource.errorLoadingDataMessage', { + defaultMessage: 'There was an error loading this data source information', + }) + } + /> + ) : ( + <> + {formState === 'CONFIRM' && ( + setFormState('VALID')} + /> + )} + + ), + }, + { + title: i18n.translate( + 'xpack.ingestManager.editDatasource.stepConfgiureDatasourceTitle', + { + defaultMessage: 'Select the data you want to collect', + } + ), + children: ( + + ), + }, + ]} + /> + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx index 71ada155373bf..ef88aa5d17f1e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx @@ -8,10 +8,14 @@ import { HashRouter as Router, Switch, Route } from 'react-router-dom'; import { AgentConfigListPage } from './list_page'; import { AgentConfigDetailsPage } from './details_page'; import { CreateDatasourcePage } from './create_datasource_page'; +import { EditDatasourcePage } from './edit_datasource_page'; export const AgentConfigApp: React.FunctionComponent = () => ( + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index 1ea162252c741..3dcc19bc4a5ae 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -36,13 +36,11 @@ import { useConfig, useUrlParams, } from '../../../hooks'; -import { AgentConfigDeleteProvider } from '../components'; import { CreateAgentConfigFlyout } from './components'; import { SearchBar } from '../../../components/search_bar'; import { LinkedAgentCount } from '../components'; import { useAgentConfigLink } from '../details_page/hooks/use_details_uri'; import { TableRowActions } from '../components/table_row_actions'; -import { DangerEuiContextMenuItem } from '../components/danger_eui_context_menu_item'; const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ overflow: 'hidden', @@ -108,30 +106,12 @@ const ConfigRowActions = memo<{ config: AgentConfig; onDelete: () => void }>( defaultMessage="Create data source" /> , - - - - , - - - {deleteAgentConfigsPrompt => { - return ( - deleteAgentConfigsPrompt([config.id], onDelete)} - > - - - ); - }} - , + // + // + // , ]} /> ); @@ -156,7 +136,6 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { : urlParams.kuery ?? '' ); const { pagination, pageSizeOptions, setPagination } = usePagination(); - const [selectedAgentConfigs, setSelectedAgentConfigs] = useState([]); const history = useHistory(); const isCreateAgentConfigFlyoutOpen = 'create' in urlParams; const setIsCreateAgentConfigFlyoutOpen = useCallback( @@ -321,34 +300,6 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { /> ) : null} - {selectedAgentConfigs.length ? ( - - - {deleteAgentConfigsPrompt => ( - { - deleteAgentConfigsPrompt( - selectedAgentConfigs.map(agentConfig => agentConfig.id), - () => { - sendRequest(); - setSelectedAgentConfigs([]); - } - ); - }} - > - - - )} - - - ) : null} = () => { items={agentConfigData ? agentConfigData.items : []} itemId="id" columns={columns} - isSelectable={true} - selection={{ - selectable: (agentConfig: AgentConfig) => !agentConfig.is_default, - onSelectionChange: (newSelectedAgentConfigs: AgentConfig[]) => { - setSelectedAgentConfigs(newSelectedAgentConfigs); - }, - }} + isSelectable={false} pagination={{ pageIndex: pagination.currentPage - 1, pageSize: pagination.pageSize, 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 2f78ecd1b085e..1508f4dfaa628 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 @@ -32,6 +32,8 @@ export { // API schemas - Datasource CreateDatasourceRequest, CreateDatasourceResponse, + UpdateDatasourceRequest, + UpdateDatasourceResponse, // API schemas - Data Streams GetDataStreamsResponse, // API schemas - Agents diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 42298960cc615..69f14854cdd0f 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -22,6 +22,7 @@ import { } from '../../types'; import { GetAgentConfigsResponse, + GetAgentConfigsResponseItem, GetOneAgentConfigResponse, CreateAgentConfigResponse, UpdateAgentConfigResponse, @@ -46,7 +47,7 @@ export const getAgentConfigsHandler: RequestHandler< await bluebird.map( items, - agentConfig => + (agentConfig: GetAgentConfigsResponseItem) => listAgents(soClient, { showInactive: true, perPage: 0, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8974f0b5b4d58..81dc44f3a4cb4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8225,11 +8225,8 @@ "xpack.ingestManager.agentConfigList.addButton": "エージェント構成を作成", "xpack.ingestManager.agentConfigList.agentsColumnTitle": "エージェント", "xpack.ingestManager.agentConfigList.clearFiltersLinkText": "フィルターを消去", - "xpack.ingestManager.agentConfigList.copyConfigActionText": "構成をコピー", "xpack.ingestManager.agentConfigList.createDatasourceActionText": "データソースを作成", "xpack.ingestManager.agentConfigList.datasourcesCountColumnTitle": "データソース", - "xpack.ingestManager.agentConfigList.deleteButton": "{count, plural, one {# エージェント設定} other {# エージェント設定}}を削除", - "xpack.ingestManager.agentConfigList.deleteConfigActionText": "構成の削除", "xpack.ingestManager.agentConfigList.descriptionColumnTitle": "説明", "xpack.ingestManager.agentConfigList.loadingAgentConfigsMessage": "エージェント構成を読み込み中...", "xpack.ingestManager.agentConfigList.nameColumnTitle": "名前", @@ -8313,7 +8310,6 @@ "xpack.ingestManager.configDetails.configDetailsTitle": "構成「{id}」", "xpack.ingestManager.configDetails.configNotFoundErrorTitle": "構成「{id}」が見つかりません", "xpack.ingestManager.configDetails.datasourcesTable.actionsColumnTitle": "アクション", - "xpack.ingestManager.configDetails.datasourcesTable.copyActionTitle": "データソースをコピー", "xpack.ingestManager.configDetails.datasourcesTable.deleteActionTitle": "データソースを削除", "xpack.ingestManager.configDetails.datasourcesTable.descriptionColumnTitle": "説明", "xpack.ingestManager.configDetails.datasourcesTable.editActionTitle": "データソースを編集", @@ -8321,7 +8317,6 @@ "xpack.ingestManager.configDetails.datasourcesTable.namespaceColumnTitle": "名前空間", "xpack.ingestManager.configDetails.datasourcesTable.packageNameColumnTitle": "パッケージ", "xpack.ingestManager.configDetails.datasourcesTable.streamsCountColumnTitle": "ストリーム", - "xpack.ingestManager.configDetails.datasourcesTable.viewActionTitle": "データソースを表示", "xpack.ingestManager.configDetails.subTabs.datasouces": "データソース", "xpack.ingestManager.configDetails.subTabs.settings": "設定", "xpack.ingestManager.configDetails.subTabs.yamlFile": "YAML ファイル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d36a62f15aee9..e06edb45de8fa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8228,11 +8228,8 @@ "xpack.ingestManager.agentConfigList.addButton": "创建代理配置", "xpack.ingestManager.agentConfigList.agentsColumnTitle": "代理", "xpack.ingestManager.agentConfigList.clearFiltersLinkText": "清除筛选", - "xpack.ingestManager.agentConfigList.copyConfigActionText": "复制配置", "xpack.ingestManager.agentConfigList.createDatasourceActionText": "创建数据源", "xpack.ingestManager.agentConfigList.datasourcesCountColumnTitle": "数据源", - "xpack.ingestManager.agentConfigList.deleteButton": "删除 {count, plural, one {# 个代理配置} other {# 个代理配置}}", - "xpack.ingestManager.agentConfigList.deleteConfigActionText": "删除配置", "xpack.ingestManager.agentConfigList.descriptionColumnTitle": "描述", "xpack.ingestManager.agentConfigList.loadingAgentConfigsMessage": "正在加载代理配置……", "xpack.ingestManager.agentConfigList.nameColumnTitle": "名称", @@ -8316,7 +8313,6 @@ "xpack.ingestManager.configDetails.configDetailsTitle": "配置“{id}”", "xpack.ingestManager.configDetails.configNotFoundErrorTitle": "未找到配置“{id}”", "xpack.ingestManager.configDetails.datasourcesTable.actionsColumnTitle": "操作", - "xpack.ingestManager.configDetails.datasourcesTable.copyActionTitle": "复制数据源", "xpack.ingestManager.configDetails.datasourcesTable.deleteActionTitle": "删除数据源", "xpack.ingestManager.configDetails.datasourcesTable.descriptionColumnTitle": "描述", "xpack.ingestManager.configDetails.datasourcesTable.editActionTitle": "编辑数据源", @@ -8324,7 +8320,6 @@ "xpack.ingestManager.configDetails.datasourcesTable.namespaceColumnTitle": "命名空间", "xpack.ingestManager.configDetails.datasourcesTable.packageNameColumnTitle": "软件包", "xpack.ingestManager.configDetails.datasourcesTable.streamsCountColumnTitle": "流计数", - "xpack.ingestManager.configDetails.datasourcesTable.viewActionTitle": "查看数据源", "xpack.ingestManager.configDetails.subTabs.datasouces": "数据源", "xpack.ingestManager.configDetails.subTabs.settings": "设置", "xpack.ingestManager.configDetails.subTabs.yamlFile": "YAML 文件",